Files
food-ai/backend/internal/domain/menu/handler.go
dbastrikin 9580bff54e feat: flexible meal planning wizard — plan 1 meal, 1 day, several days, or a week
Backend:
- migration 005: expand menu_items.meal_type CHECK to all 6 types (second_breakfast, afternoon_snack, snack)
- ai/types.go: add Days and MealTypes to MenuRequest for partial generation
- openai/menu.go: parametrize GenerateMenu — use requested meal types and day count; add caloric fractions for all 6 meal types
- menu/repository.go: add UpsertItemsInTx for partial upsert (preserves existing slots); fix meal_type sort order in GetByWeek
- menu/handler.go: add dates+meal_types path to POST /ai/generate-menu; extract fetchImages/saveRecipes helpers; returns {"plans":[...]} for dates mode; backward-compatible with week mode

Client:
- PlanMenuSheet: bottom sheet with 4 planning horizon options
- PlanDatePickerSheet: adaptive sheet with date strip (single day/meal) or custom CalendarRangePicker (multi-day/week); sliding 7-day window for week mode
- menu_service.dart: add generateForDates
- menu_provider.dart: add PlanMenuService (generates + invalidates week providers), lastPlannedDateProvider
- home_screen.dart: add _PlanMenuButton card below quick actions; opens planning wizard
- l10n: 16 new keys for planning UI across all 12 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:10:52 +02:00

702 lines
22 KiB
Go

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)
}
// 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
}
_ = 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)
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 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 []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 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)
}