package bookings import ( "crypto/sha256" "encoding/json" "fmt" "log" "log/slog" "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 (set by OptionalAuth middleware). // The inline Bearer fallback below is a safety net for the 22+ test // invocations that call ReserveSlotHandler via http.HandlerFunc directly // (without middleware). Keeping it is deliberate: the fallback never // executes in production (middleware always sets context first), removing it // would require refactoring those tests, and it acts as defense-in-depth // against accidental middleware misconfiguration. userID, hasUser := r.Context().Value(mw.UserIDKey).(string) hasAuth := hasUser && userID != "" if !hasAuth { authHeader := r.Header.Get("Authorization") if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok { tokenString := after var err error userID, _, _, err = auth.VerifyToken(tokenString, r.Context()) if err != nil { 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", sha256.Sum256([]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 func() { if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() // 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", sha256.Sum256([]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 func() { if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() // 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) }