package menu import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strings" "sync" "time" "github.com/food-ai/backend/internal/gemini" "github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/savedrecipe" "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 persists a single recipe and returns the stored record. type RecipeSaver interface { Save(ctx context.Context, userID string, req savedrecipe.SaveRequest) (*savedrecipe.SavedRecipe, 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) // Attach pantry products. if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { menuReq.AvailableProducts = products } // Generate 7-day plan via Groq. 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 } // Save all 21 recipes to saved_recipes. 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 { saved, err := h.recipeSaver.Save(r.Context(), userID, recipeToSaveRequest(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, saved.ID}) } } // 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 { 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{} categories := map[string]string{} // name → category (from meal_type heuristic) for _, row := range rows { var ingredients []struct { Name string `json:"name"` Amount float64 `json:"amount"` Unit string `json:"unit"` } if len(row.IngredientsJSON) > 0 { if err := json.Unmarshal(row.IngredientsJSON, &ingredients); err != nil { continue } } for _, ing := range ingredients { k := key{strings.ToLower(strings.TrimSpace(ing.Name)), ing.Unit} totals[k] += ing.Amount if _, ok := categories[k.name]; !ok { categories[k.name] = "other" } } } items := make([]ShoppingItem, 0, len(totals)) for k, amount := range totals { items = append(items, ShoppingItem{ Name: k.name, Category: categories[k.name], 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) gemini.MenuRequest { req := gemini.MenuRequest{DailyCalories: 2000} 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 } func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest { ingJSON, _ := json.Marshal(r.Ingredients) stepsJSON, _ := json.Marshal(r.Steps) tagsJSON, _ := json.Marshal(r.Tags) nutritionJSON, _ := json.Marshal(r.Nutrition) return savedrecipe.SaveRequest{ Title: r.Title, Description: r.Description, Cuisine: r.Cuisine, Difficulty: r.Difficulty, PrepTimeMin: r.PrepTimeMin, CookTimeMin: r.CookTimeMin, Servings: r.Servings, ImageURL: r.ImageURL, Ingredients: ingJSON, Steps: stepsJSON, Tags: tagsJSON, Nutrition: nutritionJSON, Source: "menu", } } // 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) }