Files
Crussell/backend/handlers/bookings/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

335 lines
11 KiB
Go

package bookings
import (
"crypto/md5"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"crussell/auth"
"crussell/clock"
"crussell/db"
"crussell/handlers/scheduling"
"crussell/internal/validators"
"crussell/mw"
"net"
)
// ReserveSlotRequest represents the request body for reserving a slot
type ReserveSlotRequest struct {
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
}
// ReserveSlotResponse represents the response for a successful reservation
type ReserveSlotResponse struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
DurationMinutes int `json:"duration_minutes"`
ExpiresAt time.Time `json:"expires_at"`
IsAnonymous bool `json:"is_anonymous"`
}
// ReserveSlotHandler creates temporary slot reservations for both logged-in and anonymous users
func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
// a. Parse JSON request body, validate start_time and service_ids are present
var req ReserveSlotRequest
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 err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if req.StartTime.IsZero() {
http.Error(w, "start_time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
// b. Extract client IP: check CF-Connecting-IP → X-Real-IP → X-Forwarded-For → RemoteAddr
ip := r.Header.Get("CF-Connecting-IP")
if ip == "" {
ip, _, _ = net.SplitHostPort(r.RemoteAddr)
if ip == "" {
ip = r.RemoteAddr
}
}
// c. Detect auth: try context first, then parse Bearer token from Authorization header
userID, hasUser := r.Context().Value(mw.UserIDKey).(string)
hasAuth := hasUser && userID != ""
// If no user in context, try to parse token from header
if !hasAuth {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
var err error
userID, _, _, err = auth.VerifyToken(tokenString, r.Context())
if err != nil {
// Invalid token - treat as unauthenticated
userID = ""
}
hasAuth = userID != ""
}
}
// d. Calculate total duration: SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
var svcDuration int
if err := db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
`, req.ServiceIDs).Scan(&svcDuration); 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
}
// e. Validate start_time not in the past
if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
// f. Validate working hours: SELECT end_time::text FROM working_hours WHERE weekday = $1
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
localStart := req.StartTime.In(londonLocation)
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 {
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 {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
return
}
// g. Check existing booking overlap (same query as CreateBookingHandler)
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
// Clean up existing reservation BEFORE the overlap check,
// using db.Conn.Exec (not tx.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 hasAuth {
// Also compute IP hash to clean up anonymous reservations
// that may have been created before the user logged in.
ipHash := fmt.Sprintf("%x", md5.Sum([]byte(ip)))[:8]
if _, delErr := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE (created_by = $1 AND description LIKE 'RESERVATION:user:%')
OR (description LIKE 'RESERVATION:anon:' || $2 || ':%')
`, userID, ipHash); delErr != nil {
log.Printf("Failed to delete existing reservation: %v", delErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap
// Pass excludeUserID so this user's own RESERVATION doesn't self-block.
// The pre-overlap DELETE (above) and excludeUserID are complementary:
// the DELETE cleans stale reservations from prior attempts; excludeUserID
// prevents any remaining own-RESERVATION entries from blocking.
var excludeUserID *string
if hasAuth {
excludeUserID = &userID
}
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime, excludeUserID)
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
}
// j. Branch on auth
var reservationID string
var createdAt time.Time
if hasAuth {
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 (TOCTOU fix)
// pending_release is excluded — those bookings are evicted at creation time.
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
}
// LOGGED IN: Delete existing reservation
_, err = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE created_by = $1 AND description LIKE 'RESERVATION:user:%'
`, userID)
if err != nil {
log.Printf("Failed to delete existing reservation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Insert new reservation
description := fmt.Sprintf("RESERVATION:user:%s:%d", userID, clock.Now().UnixNano())
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, userID).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
}
} else {
// Calculate ipHash from IP address
ipHash := fmt.Sprintf("%x", md5.Sum([]byte(ip)))[:8]
// ANONYMOUS: Use transaction for atomic rate cap + overlap check + insert
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 anon rate cap inside transaction
tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
var anonCount int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM time_blockers
WHERE description LIKE 'RESERVATION:anon:%' AND created_at > $1
`, tenMinutesAgo).Scan(&anonCount); err != nil {
log.Printf("Failed to check anon cap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if anonCount >= 50 {
http.Error(w, "Too many active reservations. Please wait or log in.", http.StatusTooManyRequests)
return
}
// Check overlap inside transaction
// pending_release is excluded — those bookings are evicted at creation time.
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 anonCnt int
for overlapRows.Next() {
anonCnt++
}
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 anonCnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
}
description := fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())
err = tx.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, NULL)
RETURNING id, created_at
`, req.StartTime, svcDuration, description).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
}
}
// k. Calculate expires_at: logged-in = created_at + 1 hour, anon = created_at + 10 minutes
var expiresAt time.Time
if hasAuth {
expiresAt = createdAt.Add(1 * time.Hour)
} else {
expiresAt = createdAt.Add(10 * time.Minute)
}
// l. Return 201 with JSON response
response := ReserveSlotResponse{
ID: reservationID,
StartTime: req.StartTime,
DurationMinutes: svcDuration,
ExpiresAt: expiresAt,
IsAnonymous: !hasAuth,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}