package recommendation import ( "context" "encoding/json" "log/slog" "net/http" "strconv" "sync" "github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/domain/user" ) // PhotoSearcher can search for a photo by text query. type PhotoSearcher interface { SearchPhoto(ctx context.Context, query string) (string, error) } // UserLoader can load a user profile by ID. type UserLoader interface { GetByID(ctx context.Context, id string) (*user.User, error) } // ProductLister returns a human-readable list of user's products for the AI prompt. type ProductLister interface { ListForPrompt(ctx context.Context, userID string) ([]string, error) } // userPreferences is the shape of user.Preferences JSONB. type userPreferences struct { Cuisines []string `json:"cuisines"` Restrictions []string `json:"restrictions"` } // RecipeGenerator generates recipe recommendations via an AI provider. type RecipeGenerator interface { GenerateRecipes(ctx context.Context, req ai.RecipeRequest) ([]ai.Recipe, error) } // RecipeTranslator translates a slice of English recipes into a target language. type RecipeTranslator interface { TranslateRecipes(ctx context.Context, recipes []ai.Recipe, targetLang string) ([]ai.Recipe, error) } // Handler handles GET /recommendations. type Handler struct { recipeGenerator RecipeGenerator translator RecipeTranslator pexels PhotoSearcher userLoader UserLoader productLister ProductLister } // NewHandler creates a new Handler. func NewHandler(recipeGenerator RecipeGenerator, translator RecipeTranslator, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler { return &Handler{ recipeGenerator: recipeGenerator, translator: translator, pexels: pexels, userLoader: userLoader, productLister: productLister, } } // GetRecommendations handles GET /recommendations?count=5. func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeErrorJSON(w, r, http.StatusUnauthorized, "unauthorized") return } count := 5 if s := r.URL.Query().Get("count"); s != "" { if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 20 { count = n } } u, err := h.userLoader.GetByID(r.Context(), userID) if err != nil { slog.ErrorContext(r.Context(), "load user for recommendations", "user_id", userID, "err", err) writeErrorJSON(w, r, http.StatusInternalServerError, "failed to load user profile") return } req := buildRecipeRequest(u, count, locale.FromContext(r.Context())) // Attach available products to personalise the prompt. if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { req.AvailableProducts = products } else { slog.WarnContext(r.Context(), "load products for recommendations", "user_id", userID, "err", err) } recipes, err := h.recipeGenerator.GenerateRecipes(r.Context(), req) if err != nil { slog.ErrorContext(r.Context(), "generate recipes", "user_id", userID, "err", err) writeErrorJSON(w, r, http.StatusServiceUnavailable, "recipe generation failed, please try again") return } // Fetch Pexels photos in parallel — each goroutine owns a distinct index. var wg sync.WaitGroup for i := range recipes { wg.Add(1) go func(i int) { defer wg.Done() imageURL, err := h.pexels.SearchPhoto(r.Context(), recipes[i].ImageQuery) if err != nil { slog.WarnContext(r.Context(), "pexels photo search failed", "query", recipes[i].ImageQuery, "err", err) } recipes[i].ImageURL = imageURL }(i) } wg.Wait() // Translate text fields into the requested language. // The generation prompt always produces English; translations are applied // in-memory here since recommendations are not persisted to the database. lang := locale.FromContext(r.Context()) if lang != "en" { translated, translateError := h.translator.TranslateRecipes(r.Context(), recipes, lang) if translateError != nil { slog.WarnContext(r.Context(), "translate recommendations", "lang", lang, "err", translateError) // Fall back to English rather than failing the request. } else { recipes = translated } } writeJSON(w, http.StatusOK, recipes) } func buildRecipeRequest(u *user.User, count int, lang string) ai.RecipeRequest { req := ai.RecipeRequest{ Count: count, DailyCalories: 2000, // sensible default Lang: lang, } if u.Goal != nil { req.UserGoal = *u.Goal } if u.DailyCalories != nil && *u.DailyCalories > 0 { req.DailyCalories = *u.DailyCalories } if len(u.Preferences) > 0 { var prefs userPreferences if err := json.Unmarshal(u.Preferences, &prefs); err == nil { req.CuisinePrefs = prefs.Cuisines req.Restrictions = prefs.Restrictions } } return req } type errorResponse struct { Error string `json:"error"` RequestID string `json:"request_id,omitempty"` } func writeErrorJSON(w http.ResponseWriter, r *http.Request, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if encodeErr := json.NewEncoder(w).Encode(errorResponse{ Error: msg, RequestID: middleware.RequestIDFromCtx(r.Context()), }); encodeErr != nil { slog.ErrorContext(r.Context(), "write error response", "err", encodeErr) } } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { slog.Error("write JSON response", "err", err) } }