diff --git a/backend/handlers/bookings/reserve.go b/backend/handlers/bookings/reserve.go index 7bbdad2..3af23d0 100644 --- a/backend/handlers/bookings/reserve.go +++ b/backend/handlers/bookings/reserve.go @@ -12,14 +12,15 @@ import ( "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"` - ServiceIDs []string `json:"service_ids"` + 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 @@ -41,6 +42,14 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) { 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 @@ -51,15 +60,15 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) { } // 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 - } - } + 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 + // c. Detect auth: try context first, then parse Bearer token from Authorization header userID, hasUser := r.Context().Value(mw.UserIDKey).(string) hasAuth := hasUser && userID != "" @@ -121,7 +130,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) { endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute) var cnt int db.DB.QueryRow(r.Context(), ` - SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') + SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(dur),60) FROM ( @@ -139,11 +148,11 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) { } // h. Check time blocker overlap using scheduling.CheckTimeBlockerOverlap - blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime) + 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, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict) + http.Error(w, "Cannot book this time - slot is blocked", http.StatusConflict) return } @@ -152,8 +161,16 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) { var createdAt time.Time if hasAuth { + tx, err := db.DB.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 := db.DB.Exec(r.Context(), ` + _, err = tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE created_by = $1 AND description LIKE 'RESERVATION:user:%' `, userID) @@ -165,7 +182,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) { // Insert new reservation description := fmt.Sprintf("RESERVATION:user:%s:%d", userID, time.Now().UnixNano()) - err = db.DB.QueryRow(r.Context(), ` + 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 @@ -175,6 +192,12 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) { 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) diff --git a/backend/handlers/bookings/reserve_test.go b/backend/handlers/bookings/reserve_test.go index fd416bb..65f6fd9 100644 --- a/backend/handlers/bookings/reserve_test.go +++ b/backend/handlers/bookings/reserve_test.go @@ -1,5 +1,5 @@ -//go:build test -// +build test +//go:build test && dev +// +build test,dev package bookings @@ -43,7 +43,7 @@ func makeReserveRequest(method, path string, body interface{}, token string) *ht // Add chi middleware stack for proper routing context r := chi.NewRouter() r.Use(middleware.RequestID) - r.Use(middleware.RealIP) + r.Use(middleware.ClientIPFromRemoteAddr) if token != "" { req.Header.Set("Authorization", "Bearer "+token) @@ -398,7 +398,8 @@ func nextWeekday(weekday time.Weekday, loc *time.Location) time.Time { if daysAhead < 2 { daysAhead += 7 } - return now.AddDate(0, 0, daysAhead).Truncate(24 * time.Hour) + next := now.AddDate(0, 0, daysAhead) + return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location()) } // TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday)