package menu import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strings" "sync" "time" "github.com/food-ai/backend/internal/dish" "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" "github.com/go-chi/chi/v5" ) // PhotoSearcher searches for a photo by query string. type PhotoSearcher interface { SearchPhoto(ctx context.Context, query string) (string, error) } // UserLoader loads a user profile by ID. type UserLoader interface { GetByID(ctx context.Context, id string) (*user.User, error) } // ProductLister returns human-readable product lines for the AI prompt. type ProductLister interface { ListForPrompt(ctx context.Context, userID string) ([]string, error) } // RecipeSaver creates a dish+recipe and returns the new recipe ID. type RecipeSaver interface { Create(ctx context.Context, req dish.CreateRequest) (string, error) } // Handler handles menu and shopping-list endpoints. type Handler struct { repo *Repository gemini *gemini.Client pexels PhotoSearcher userLoader UserLoader productLister ProductLister recipeSaver RecipeSaver } // NewHandler creates a new Handler. func NewHandler( repo *Repository, geminiClient *gemini.Client, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister, recipeSaver RecipeSaver, ) *Handler { return &Handler{ repo: repo, gemini: geminiClient, pexels: pexels, userLoader: userLoader, productLister: productLister, recipeSaver: recipeSaver, } } // ──────────────────────────────────────────────────────────── // Menu endpoints // ──────────────────────────────────────────────────────────── // GetMenu handles GET /menu?week=YYYY-WNN func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } weekStart, err := resolveWeekStart(r.URL.Query().Get("week")) if err != nil { writeError(w, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN") return } plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) if err != nil { slog.Error("get menu", "err", err) writeError(w, http.StatusInternalServerError, "failed to load menu") return } if plan == nil { // No plan yet — return empty response. writeJSON(w, http.StatusOK, map[string]any{ "week_start": weekStart, "days": nil, }) return } writeJSON(w, http.StatusOK, plan) } // GenerateMenu handles POST /ai/generate-menu func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } var body struct { Week string `json:"week"` // optional, defaults to current week } _ = json.NewDecoder(r.Body).Decode(&body) weekStart, err := resolveWeekStart(body.Week) if err != nil { writeError(w, http.StatusBadRequest, "invalid week parameter") return } // Load user profile. u, err := h.userLoader.GetByID(r.Context(), userID) if err != nil { slog.Error("load user for menu generation", "err", err) writeError(w, http.StatusInternalServerError, "failed to load user profile") return } menuReq := buildMenuRequest(u, locale.FromContext(r.Context())) // Attach pantry products. if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { menuReq.AvailableProducts = products } // Generate 7-day plan via Gemini. days, err := h.gemini.GenerateMenu(r.Context(), menuReq) if err != nil { slog.Error("generate menu", "user_id", userID, "err", err) writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again") return } // Fetch Pexels images for all 21 recipes in parallel. type indexedRecipe struct { day int meal int imageURL string } imageResults := make([]indexedRecipe, 0, len(days)*3) var mu sync.Mutex var wg sync.WaitGroup for di, day := range days { for mi := range day.Meals { wg.Add(1) go func(di, mi int, query string) { defer wg.Done() url, err := h.pexels.SearchPhoto(r.Context(), query) if err != nil { slog.Warn("pexels search failed", "query", query, "err", err) } mu.Lock() imageResults = append(imageResults, indexedRecipe{di, mi, url}) mu.Unlock() }(di, mi, day.Meals[mi].Recipe.ImageQuery) } } wg.Wait() for _, res := range imageResults { days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL } // Persist all 21 recipes as dish+recipe rows. type savedRef struct { day int meal int recipeID string } refs := make([]savedRef, 0, len(days)*3) for di, day := range days { for mi, meal := range day.Meals { recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe)) if err != nil { slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err) writeError(w, http.StatusInternalServerError, "failed to save recipes") return } refs = append(refs, savedRef{di, mi, recipeID}) } } // Build PlanItems list in day/meal order. planItems := make([]PlanItem, 0, 21) for _, ref := range refs { planItems = append(planItems, PlanItem{ DayOfWeek: days[ref.day].Day, MealType: days[ref.day].Meals[ref.meal].MealType, RecipeID: ref.recipeID, }) } // Persist in a single transaction. planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems) if err != nil { slog.Error("save menu plan", "err", err) writeError(w, http.StatusInternalServerError, "failed to save menu plan") return } // Auto-generate shopping list. if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil { if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); err != nil { slog.Warn("auto-generate shopping list", "err", err) } } // Return the freshly saved plan. plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) if err != nil || plan == nil { slog.Error("load generated menu", "err", err, "plan_nil", plan == nil) writeError(w, http.StatusInternalServerError, "failed to load generated menu") return } writeJSON(w, http.StatusOK, plan) } // UpdateMenuItem handles PUT /menu/items/{id} func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } itemID := chi.URLParam(r, "id") var body struct { RecipeID string `json:"recipe_id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RecipeID == "" { writeError(w, http.StatusBadRequest, "recipe_id required") return } if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil { if err == ErrNotFound { writeError(w, http.StatusNotFound, "menu item not found") return } slog.Error("update menu item", "err", err) writeError(w, http.StatusInternalServerError, "failed to update menu item") return } w.WriteHeader(http.StatusNoContent) } // DeleteMenuItem handles DELETE /menu/items/{id} func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } itemID := chi.URLParam(r, "id") if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil { if err == ErrNotFound { writeError(w, http.StatusNotFound, "menu item not found") return } slog.Error("delete menu item", "err", err) writeError(w, http.StatusInternalServerError, "failed to delete menu item") return } w.WriteHeader(http.StatusNoContent) } // ──────────────────────────────────────────────────────────── // Shopping list endpoints // ──────────────────────────────────────────────────────────── // GenerateShoppingList handles POST /shopping-list/generate func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } var body struct { Week string `json:"week"` } _ = json.NewDecoder(r.Body).Decode(&body) weekStart, err := resolveWeekStart(body.Week) if err != nil { writeError(w, http.StatusBadRequest, "invalid week parameter") return } planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) if err != nil { if err == ErrNotFound { writeError(w, http.StatusNotFound, "no menu plan found for this week") return } slog.Error("get plan id", "err", err) writeError(w, http.StatusInternalServerError, "failed to find menu plan") return } items, err := h.buildShoppingList(r.Context(), planID) if err != nil { slog.Error("build shopping list", "err", err) writeError(w, http.StatusInternalServerError, "failed to build shopping list") return } if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil { slog.Error("upsert shopping list", "err", err) writeError(w, http.StatusInternalServerError, "failed to save shopping list") return } writeJSON(w, http.StatusOK, items) } // GetShoppingList handles GET /shopping-list?week=YYYY-WNN func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } weekStart, err := resolveWeekStart(r.URL.Query().Get("week")) if err != nil { writeError(w, http.StatusBadRequest, "invalid week parameter") return } planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) if err != nil { if err == ErrNotFound { writeJSON(w, http.StatusOK, []ShoppingItem{}) return } writeError(w, http.StatusInternalServerError, "failed to find menu plan") return } items, err := h.repo.GetShoppingList(r.Context(), userID, planID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load shopping list") return } if items == nil { items = []ShoppingItem{} } writeJSON(w, http.StatusOK, items) } // ToggleShoppingItem handles PATCH /shopping-list/items/{index}/check func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } indexStr := chi.URLParam(r, "index") var index int if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil || index < 0 { writeError(w, http.StatusBadRequest, "invalid item index") return } var body struct { Checked bool `json:"checked"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } weekStart, err := resolveWeekStart(r.URL.Query().Get("week")) if err != nil { writeError(w, http.StatusBadRequest, "invalid week parameter") return } planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) if err != nil { writeError(w, http.StatusNotFound, "menu plan not found") return } if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil { slog.Error("toggle shopping item", "err", err) writeError(w, http.StatusInternalServerError, "failed to update item") return } w.WriteHeader(http.StatusNoContent) } // ──────────────────────────────────────────────────────────── // Helpers // ──────────────────────────────────────────────────────────── // buildShoppingList aggregates all ingredients from a plan's recipes. func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]ShoppingItem, error) { rows, err := h.repo.GetIngredientsByPlan(ctx, planID) if err != nil { return nil, err } type key struct{ name, unit string } totals := map[key]float64{} for _, row := range rows { unit := "" if row.UnitCode != nil { unit = *row.UnitCode } k := key{strings.ToLower(strings.TrimSpace(row.Name)), unit} totals[k] += row.Amount } items := make([]ShoppingItem, 0, len(totals)) for k, amount := range totals { items = append(items, ShoppingItem{ Name: k.name, Category: "other", Amount: amount, Unit: k.unit, Checked: false, InStock: 0, }) } return items, nil } type userPreferences struct { Cuisines []string `json:"cuisines"` Restrictions []string `json:"restrictions"` } func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest { req := gemini.MenuRequest{DailyCalories: 2000, 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 } // recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest. func recipeToCreateRequest(r gemini.Recipe) dish.CreateRequest { cr := dish.CreateRequest{ Name: r.Title, Description: r.Description, CuisineSlug: mapCuisineSlug(r.Cuisine), ImageURL: r.ImageURL, Difficulty: r.Difficulty, PrepTimeMin: r.PrepTimeMin, CookTimeMin: r.CookTimeMin, Servings: r.Servings, Calories: r.Nutrition.Calories, Protein: r.Nutrition.ProteinG, Fat: r.Nutrition.FatG, Carbs: r.Nutrition.CarbsG, Source: "menu", } for _, ing := range r.Ingredients { cr.Ingredients = append(cr.Ingredients, dish.IngredientInput{ Name: ing.Name, Amount: ing.Amount, Unit: ing.Unit, }) } for _, s := range r.Steps { cr.Steps = append(cr.Steps, dish.StepInput{ Number: s.Number, Description: s.Description, TimerSeconds: s.TimerSeconds, }) } cr.Tags = append(cr.Tags, r.Tags...) return cr } // mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug. // Falls back to "other". func mapCuisineSlug(cuisine string) string { known := map[string]string{ "russian": "russian", "italian": "italian", "french": "french", "chinese": "chinese", "japanese": "japanese", "korean": "korean", "mexican": "mexican", "mediterranean": "mediterranean", "indian": "indian", "thai": "thai", "american": "american", "georgian": "georgian", "spanish": "spanish", "german": "german", "middle_eastern": "middle_eastern", "turkish": "turkish", "greek": "greek", "vietnamese": "vietnamese", "asian": "other", "european": "other", } if s, ok := known[cuisine]; ok { return s } return "other" } // resolveWeekStart parses "YYYY-WNN" or returns current week's Monday. func resolveWeekStart(week string) (string, error) { if week == "" { return currentWeekStart(), nil } var year, w int if _, err := fmt.Sscanf(week, "%d-W%d", &year, &w); err != nil || w < 1 || w > 53 { return "", fmt.Errorf("invalid week: %q", week) } t := mondayOfISOWeek(year, w) return t.Format("2006-01-02"), nil } func currentWeekStart() string { now := time.Now().UTC() year, week := now.ISOWeek() return mondayOfISOWeek(year, week).Format("2006-01-02") } func mondayOfISOWeek(year, week int) time.Time { jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC) weekday := int(jan4.Weekday()) if weekday == 0 { weekday = 7 } monday1 := jan4.AddDate(0, 0, 1-weekday) return monday1.AddDate(0, 0, (week-1)*7) } type errorResponse struct { Error string `json:"error"` } func writeError(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }