package menu import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strings" "sync" "time" "github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/domain/dish" "github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/domain/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) // ListForPromptByIDs returns only the products with the given IDs. ListForPromptByIDs(ctx context.Context, userID string, ids []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) } // MenuGenerator generates a 7-day meal plan via an AI provider. type MenuGenerator interface { GenerateMenu(ctx context.Context, req ai.MenuRequest) ([]ai.DayPlan, error) } // Handler handles menu and shopping-list endpoints. type Handler struct { repo *Repository menuGenerator MenuGenerator pexels PhotoSearcher userLoader UserLoader productLister ProductLister recipeSaver RecipeSaver } // NewHandler creates a new Handler. func NewHandler( repo *Repository, menuGenerator MenuGenerator, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister, recipeSaver RecipeSaver, ) *Handler { return &Handler{ repo: repo, menuGenerator: menuGenerator, 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, r, http.StatusUnauthorized, "unauthorized") return } weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) if err != nil { writeError(w, r, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN") return } plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart) if err != nil { slog.ErrorContext(r.Context(), "get menu", "err", err) writeError(w, r, 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 // // Two modes: // - dates mode (body.Dates non-empty): generate for specific dates and meal types, // upsert only those slots; returns {"plans":[...]}. // - week mode (existing): generate full 7-day week; returns a single MenuPlan. func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { userID := middleware.UserIDFromCtx(r.Context()) if userID == "" { writeError(w, r, http.StatusUnauthorized, "unauthorized") return } var body struct { Week string `json:"week"` // optional, defaults to current week Dates []string `json:"dates"` // YYYY-MM-DD; triggers partial generation MealTypes []string `json:"meal_types"` // overrides user preference when set ProductIDs []string `json:"product_ids"` // when set, only these products are passed to AI } _ = json.NewDecoder(r.Body).Decode(&body) // Load user profile (needed for both paths). u, loadError := h.userLoader.GetByID(r.Context(), userID) if loadError != nil { slog.ErrorContext(r.Context(), "load user for menu generation", "err", loadError) writeError(w, r, http.StatusInternalServerError, "failed to load user profile") return } if len(body.Dates) > 0 { h.generateForDates(w, r, userID, u, body.Dates, body.MealTypes, body.ProductIDs) return } // ── Full-week path (existing behaviour) ────────────────────────────── weekStart, weekError := ResolveWeekStart(body.Week) if weekError != nil { writeError(w, r, http.StatusBadRequest, "invalid week parameter") return } menuReq := buildMenuRequest(u, locale.FromContext(r.Context())) if len(body.ProductIDs) > 0 { if products, productError := h.productLister.ListForPromptByIDs(r.Context(), userID, body.ProductIDs); productError == nil { menuReq.AvailableProducts = products } } else { if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil { menuReq.AvailableProducts = products } } days, generateError := h.menuGenerator.GenerateMenu(r.Context(), menuReq) if generateError != nil { slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", generateError) writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again") return } h.fetchImages(r.Context(), days) planItems, saveError := h.saveRecipes(r.Context(), days) if saveError != nil { writeError(w, r, http.StatusInternalServerError, "failed to save recipes") return } planID, txError := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems) if txError != nil { slog.ErrorContext(r.Context(), "save menu plan", "err", txError) writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") return } if shoppingItems, shoppingError := h.buildShoppingList(r.Context(), planID); shoppingError == nil { if upsertError := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); upsertError != nil { slog.WarnContext(r.Context(), "auto-generate shopping list", "err", upsertError) } } plan, loadPlanError := h.repo.GetByWeek(r.Context(), userID, weekStart) if loadPlanError != nil || plan == nil { slog.ErrorContext(r.Context(), "load generated menu", "err", loadPlanError, "plan_nil", plan == nil) writeError(w, r, http.StatusInternalServerError, "failed to load generated menu") return } writeJSON(w, http.StatusOK, plan) } // generateForDates handles partial menu generation for specific dates and meal types. // It groups dates by ISO week, generates only the requested slots, upserts them // without touching other existing slots, and returns {"plans":[...]}. func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userID string, u *user.User, dates, requestedMealTypes, productIDs []string) { mealTypes := requestedMealTypes if len(mealTypes) == 0 { // Fall back to user's preferred meal types. var prefs struct { MealTypes []string `json:"meal_types"` } if len(u.Preferences) > 0 { _ = json.Unmarshal(u.Preferences, &prefs) } mealTypes = prefs.MealTypes } if len(mealTypes) == 0 { mealTypes = []string{"breakfast", "lunch", "dinner"} } menuReq := buildMenuRequest(u, locale.FromContext(r.Context())) menuReq.MealTypes = mealTypes if len(productIDs) > 0 { if products, productError := h.productLister.ListForPromptByIDs(r.Context(), userID, productIDs); productError == nil { menuReq.AvailableProducts = products } } else { if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil { menuReq.AvailableProducts = products } } weekGroups := groupDatesByWeek(dates) var plans []*MenuPlan for weekStart, datesInWeek := range weekGroups { menuReq.Days = datesToDOW(datesInWeek) days, generateError := h.menuGenerator.GenerateMenu(r.Context(), menuReq) if generateError != nil { slog.ErrorContext(r.Context(), "generate menu for dates", "week", weekStart, "err", generateError) writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again") return } h.fetchImages(r.Context(), days) planItems, saveError := h.saveRecipes(r.Context(), days) if saveError != nil { writeError(w, r, http.StatusInternalServerError, "failed to save recipes") return } planID, upsertError := h.repo.UpsertItemsInTx(r.Context(), userID, weekStart, planItems) if upsertError != nil { slog.ErrorContext(r.Context(), "upsert menu items", "week", weekStart, "err", upsertError) writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") return } if shoppingItems, shoppingError := h.buildShoppingList(r.Context(), planID); shoppingError == nil { if upsertShoppingError := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); upsertShoppingError != nil { slog.WarnContext(r.Context(), "auto-generate shopping list", "err", upsertShoppingError) } } plan, loadError := h.repo.GetByWeek(r.Context(), userID, weekStart) if loadError != nil || plan == nil { slog.ErrorContext(r.Context(), "load generated plan", "week", weekStart, "err", loadError) writeError(w, r, http.StatusInternalServerError, "failed to load generated menu") return } plans = append(plans, plan) } writeJSON(w, http.StatusOK, map[string]any{"plans": plans}) } // fetchImages fetches Pexels images for all meals in parallel, mutating days in place. func (h *Handler) fetchImages(ctx context.Context, days []ai.DayPlan) { type indexedResult struct { day int meal int imageURL string } imageResults := make([]indexedResult, 0, len(days)*6) var mu sync.Mutex var wg sync.WaitGroup for dayIndex, day := range days { for mealIndex := range day.Meals { wg.Add(1) go func(di, mi int, query string) { defer wg.Done() url, fetchError := h.pexels.SearchPhoto(ctx, query) if fetchError != nil { slog.WarnContext(ctx, "pexels search failed", "query", query, "err", fetchError) } mu.Lock() imageResults = append(imageResults, indexedResult{di, mi, url}) mu.Unlock() }(dayIndex, mealIndex, day.Meals[mealIndex].Recipe.ImageQuery) } } wg.Wait() for _, result := range imageResults { days[result.day].Meals[result.meal].Recipe.ImageURL = result.imageURL } } // saveRecipes persists all recipes as dish+recipe rows and returns a PlanItem list. func (h *Handler) saveRecipes(ctx context.Context, days []ai.DayPlan) ([]PlanItem, error) { planItems := make([]PlanItem, 0, len(days)*6) for _, day := range days { for _, meal := range day.Meals { recipeID, createError := h.recipeSaver.Create(ctx, recipeToCreateRequest(meal.Recipe)) if createError != nil { slog.ErrorContext(ctx, "save recipe for menu", "title", meal.Recipe.Title, "err", createError) return nil, createError } planItems = append(planItems, PlanItem{ DayOfWeek: day.Day, MealType: meal.MealType, RecipeID: recipeID, }) } } return planItems, nil } // groupDatesByWeek groups YYYY-MM-DD date strings by their ISO week's Monday date. func groupDatesByWeek(dates []string) map[string][]string { result := map[string][]string{} for _, date := range dates { t, parseError := time.Parse("2006-01-02", date) if parseError != nil { continue } year, week := t.ISOWeek() weekStart := mondayOfISOWeek(year, week).Format("2006-01-02") result[weekStart] = append(result[weekStart], date) } return result } // datesToDOW converts date strings to ISO day-of-week values (1=Monday, 7=Sunday). func datesToDOW(dates []string) []int { dows := make([]int, 0, len(dates)) for _, date := range dates { t, parseError := time.Parse("2006-01-02", date) if parseError != nil { continue } weekday := int(t.Weekday()) if weekday == 0 { weekday = 7 // Go's Sunday=0 → ISO Sunday=7 } dows = append(dows, weekday) } return dows } // 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, r, 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, r, http.StatusBadRequest, "recipe_id required") return } if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil { if err == ErrNotFound { writeError(w, r, http.StatusNotFound, "menu item not found") return } slog.ErrorContext(r.Context(), "update menu item", "err", err) writeError(w, r, 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, r, 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, r, http.StatusNotFound, "menu item not found") return } slog.ErrorContext(r.Context(), "delete menu item", "err", err) writeError(w, r, 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, r, 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, r, http.StatusBadRequest, "invalid week parameter") return } planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) if err != nil { if err == ErrNotFound { writeError(w, r, http.StatusNotFound, "no menu plan found for this week") return } slog.ErrorContext(r.Context(), "get plan id", "err", err) writeError(w, r, http.StatusInternalServerError, "failed to find menu plan") return } items, err := h.buildShoppingList(r.Context(), planID) if err != nil { slog.ErrorContext(r.Context(), "build shopping list", "err", err) writeError(w, r, http.StatusInternalServerError, "failed to build shopping list") return } if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil { slog.ErrorContext(r.Context(), "upsert shopping list", "err", err) writeError(w, r, 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, r, http.StatusUnauthorized, "unauthorized") return } weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) if err != nil { writeError(w, r, 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, r, http.StatusInternalServerError, "failed to find menu plan") return } items, err := h.repo.GetShoppingList(r.Context(), userID, planID) if err != nil { writeError(w, r, 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, r, 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, r, 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, r, http.StatusBadRequest, "invalid request body") return } weekStart, err := ResolveWeekStart(r.URL.Query().Get("week")) if err != nil { writeError(w, r, http.StatusBadRequest, "invalid week parameter") return } planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart) if err != nil { writeError(w, r, http.StatusNotFound, "menu plan not found") return } if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil { slog.ErrorContext(r.Context(), "toggle shopping item", "err", err) writeError(w, r, 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) ai.MenuRequest { req := ai.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 ai.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"` RequestID string `json:"request_id,omitempty"` } func writeError(w http.ResponseWriter, r *http.Request, status int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(errorResponse{ Error: msg, RequestID: middleware.RequestIDFromCtx(r.Context()), }) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }