Files
Crussell/backend/handlers/bookings/reserve.go
T
popertotsandSisyphus 2ba7b85d0b feat: add OptionalAuth middleware and slot reservation endpoint
OptionalAuth extracts user info from Bearer token if present,
otherwise passes through without requiring authentication.

ReserveSlotHandler creates temporary time_blockers reservations:
- Logged-in users: max 1 active, 1hr TTL, tracked by user_id
- Anonymous users: global cap of 50, 10min TTL, tracked by IP hash
- Validates slot availability (working hours, booking overlap, blocker overlap)
- Returns reservation ID, start_time, duration, and expires_at

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-29 22:56:53 +01:00

232 lines
7.7 KiB
Go

package bookings
import (
"crypto/md5"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"crussell/auth"
"crussell/db"
"crussell/handlers/scheduling"
"crussell/mw"
)
// ReserveSlotRequest represents the request body for reserving a slot
type ReserveSlotRequest struct {
StartTime time.Time `json:"start_time"`
ServiceIDs []string `json:"service_ids"`
}
// 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 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 = r.Header.Get("X-Real-IP")
}
if ip == "" {
ip = r.Header.Get("X-Forwarded-For")
}
if ip == "" {
ip = r.RemoteAddr
}
// Take first IP if multiple are in X-Forwarded-For
if strings.Contains(ip, ",") {
ip = strings.Split(strings.TrimSpace(ip), ",")[0]
}
// 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.DB.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(time.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
weekday := int(req.StartTime.Weekday())
var closeStr string
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text 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
}
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
closeTime, _ := time.Parse("15:04:05", closeStr)
if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) {
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)
var cnt int
db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60)
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
)) > $1
`, req.StartTime, endTime).Scan(&cnt)
if cnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
}
// h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap
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
}
// j. Branch on auth
var reservationID string
var createdAt time.Time
if hasAuth {
// LOGGED IN: Delete existing reservation
_, err := db.DB.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, time.Now().UnixNano())
err = db.DB.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
}
} else {
// ANONYMOUS: Check cap (50 in 10 minutes)
tenMinutesAgo := time.Now().Add(-10 * time.Minute)
var anonCount int
if err := db.DB.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
}
// Calculate ipHash: first 8 chars of md5 hex of the IP string
ipHash := fmt.Sprintf("%x", md5.Sum([]byte(ip)))[:8]
description := fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, time.Now().UnixNano())
// Insert new reservation with created_by = NULL
err = db.DB.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
}
}
// 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)
}