package recommendation import ( "context" "encoding/json" "log/slog" "net/http" "strconv" "sync" "github.com/food-ai/backend/internal/gemini" "github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/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"` } // Handler handles GET /recommendations. type Handler struct { gemini *gemini.Client pexels PhotoSearcher userLoader UserLoader productLister ProductLister } // NewHandler creates a new Handler. func NewHandler(geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler { return &Handler{ gemini: geminiClient, 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, 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.Error("load user for recommendations", "user_id", userID, "err", err) writeErrorJSON(w, 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.Warn("load products for recommendations", "user_id", userID, "err", err) } recipes, err := h.gemini.GenerateRecipes(r.Context(), req) if err != nil { slog.Error("generate recipes", "user_id", userID, "err", err) writeErrorJSON(w, 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.Warn("pexels photo search failed", "query", recipes[i].ImageQuery, "err", err) } recipes[i].ImageURL = imageURL }(i) } wg.Wait() writeJSON(w, http.StatusOK, recipes) } func buildRecipeRequest(u *user.User, count int, lang string) gemini.RecipeRequest { req := gemini.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"` } func writeErrorJSON(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { slog.Error("write error response", "err", err) } } 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) } }