package bookings import ( "crypto/md5" "encoding/json" "fmt" "log" "net/http" "strings" "time" "crussell/auth" "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(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 // 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 } localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute) closeTime, _ := time.Parse("15:04:05", closeStr) if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.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) endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) var cnt int if err := db.Conn.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1 `, req.StartTime, endTime).Scan(&cnt); err != nil { log.Printf("Failed to check overlap: %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 } // h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap blockerOverlap, _, 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, "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()) // 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, time.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 { // ANONYMOUS: Check cap (50 in 10 minutes) tenMinutesAgo := time.Now().Add(-10 * time.Minute) var anonCount int if err := db.Conn.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.Conn.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) }