diff --git a/backend/handlers/bookings/reserve.go b/backend/handlers/bookings/reserve.go new file mode 100644 index 0000000..789da70 --- /dev/null +++ b/backend/handlers/bookings/reserve.go @@ -0,0 +1,231 @@ +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) +} diff --git a/backend/mw/auth.go b/backend/mw/auth.go index bd0db3b..bc1d214 100644 --- a/backend/mw/auth.go +++ b/backend/mw/auth.go @@ -40,6 +40,26 @@ func RequireAuth(next http.Handler) http.Handler { }) } +// OptionalAuth middleware - extracts user info if token present, otherwise passes through +func OptionalAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + userID, role, err := auth.VerifyToken(tokenString, r.Context()) + if err == nil { + ctx := context.WithValue(r.Context(), UserIDKey, userID) + ctx = context.WithValue(ctx, UserRoleKey, role) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + + next.ServeHTTP(w, r) + }) +} + // RequireRole middleware - checks if user has required role(s) func RequireRole(allowedRoles ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler {