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>
This commit is contained in:
@@ -51,7 +51,7 @@ type NutritionInfo struct {
|
||||
Approximate bool `json:"approximate"`
|
||||
}
|
||||
|
||||
// MenuRequest contains parameters for weekly menu generation.
|
||||
// MenuRequest contains parameters for menu generation.
|
||||
type MenuRequest struct {
|
||||
UserGoal string
|
||||
DailyCalories int
|
||||
@@ -59,6 +59,12 @@ type MenuRequest struct {
|
||||
CuisinePrefs []string
|
||||
AvailableProducts []string
|
||||
Lang string // ISO 639-1 target language code, e.g. "en", "ru"
|
||||
// Days specifies which day-of-week slots (1=Monday…7=Sunday) to generate.
|
||||
// When nil, all 7 days are generated.
|
||||
Days []int
|
||||
// MealTypes restricts generation to the listed meal types.
|
||||
// When nil, defaults to ["breakfast","lunch","dinner"].
|
||||
MealTypes []string
|
||||
}
|
||||
|
||||
// DayPlan is the AI-generated plan for a single day.
|
||||
|
||||
@@ -8,72 +8,91 @@ import (
|
||||
"github.com/food-ai/backend/internal/adapters/ai"
|
||||
)
|
||||
|
||||
// GenerateMenu produces a 7-day × 3-meal plan by issuing three parallel
|
||||
// GenerateRecipes calls (one per meal type). This avoids token-limit errors
|
||||
// that arise from requesting 21 full recipes in a single prompt.
|
||||
// caloricFractions maps each meal type to its share of the daily calorie budget.
|
||||
var caloricFractions = map[string]float64{
|
||||
"breakfast": 0.25,
|
||||
"second_breakfast": 0.10,
|
||||
"lunch": 0.35,
|
||||
"afternoon_snack": 0.10,
|
||||
"dinner": 0.20,
|
||||
"snack": 0.10,
|
||||
}
|
||||
|
||||
// defaultMealTypes is used when MenuRequest.MealTypes is nil.
|
||||
var defaultMealTypes = []string{"breakfast", "lunch", "dinner"}
|
||||
|
||||
// GenerateMenu produces a meal plan by issuing one parallel GenerateRecipes call per meal
|
||||
// type. The number of recipes per type equals the number of days requested.
|
||||
func (c *Client) GenerateMenu(ctx context.Context, req ai.MenuRequest) ([]ai.DayPlan, error) {
|
||||
type mealSlot struct {
|
||||
mealType string
|
||||
fraction float64 // share of daily calories
|
||||
mealTypes := req.MealTypes
|
||||
if len(mealTypes) == 0 {
|
||||
mealTypes = defaultMealTypes
|
||||
}
|
||||
|
||||
slots := []mealSlot{
|
||||
{"breakfast", 0.25},
|
||||
{"lunch", 0.40},
|
||||
{"dinner", 0.35},
|
||||
days := req.Days
|
||||
if len(days) == 0 {
|
||||
days = make([]int, 7)
|
||||
for dayIndex := range days {
|
||||
days[dayIndex] = dayIndex + 1
|
||||
}
|
||||
}
|
||||
recipeCount := len(days)
|
||||
|
||||
type mealResult struct {
|
||||
recipes []ai.Recipe
|
||||
err error
|
||||
}
|
||||
|
||||
results := make([]mealResult, len(slots))
|
||||
results := make([]mealResult, len(mealTypes))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, slot := range slots {
|
||||
for slotIndex, mealType := range mealTypes {
|
||||
wg.Add(1)
|
||||
go func(idx int, mealType string, fraction float64) {
|
||||
go func(idx int, mealTypeName string) {
|
||||
defer wg.Done()
|
||||
// Scale daily calories to what this meal should contribute.
|
||||
mealCal := int(float64(req.DailyCalories) * fraction)
|
||||
r, err := c.GenerateRecipes(ctx, ai.RecipeRequest{
|
||||
fraction, ok := caloricFractions[mealTypeName]
|
||||
if !ok {
|
||||
fraction = 0.15
|
||||
}
|
||||
mealCalories := int(float64(req.DailyCalories) * fraction)
|
||||
recipes, generateError := c.GenerateRecipes(ctx, ai.RecipeRequest{
|
||||
UserGoal: req.UserGoal,
|
||||
DailyCalories: mealCal * 3, // prompt divides by 3 internally
|
||||
DailyCalories: mealCalories * recipeCount,
|
||||
Restrictions: req.Restrictions,
|
||||
CuisinePrefs: req.CuisinePrefs,
|
||||
Count: 7,
|
||||
Count: recipeCount,
|
||||
AvailableProducts: req.AvailableProducts,
|
||||
Lang: req.Lang,
|
||||
})
|
||||
results[idx] = mealResult{r, err}
|
||||
}(i, slot.mealType, slot.fraction)
|
||||
results[idx] = mealResult{recipes, generateError}
|
||||
}(slotIndex, mealType)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, res := range results {
|
||||
if res.err != nil {
|
||||
return nil, fmt.Errorf("generate %s: %w", slots[i].mealType, res.err)
|
||||
for slotIndex, result := range results {
|
||||
if result.err != nil {
|
||||
return nil, fmt.Errorf("generate %s: %w", mealTypes[slotIndex], result.err)
|
||||
}
|
||||
if len(res.recipes) == 0 {
|
||||
return nil, fmt.Errorf("no %s recipes returned", slots[i].mealType)
|
||||
if len(result.recipes) == 0 {
|
||||
return nil, fmt.Errorf("no %s recipes returned", mealTypes[slotIndex])
|
||||
}
|
||||
// Pad to exactly 7 by repeating the last recipe.
|
||||
for len(results[i].recipes) < 7 {
|
||||
results[i].recipes = append(results[i].recipes, results[i].recipes[len(results[i].recipes)-1])
|
||||
// Pad to exactly recipeCount by repeating the last recipe.
|
||||
for len(results[slotIndex].recipes) < recipeCount {
|
||||
last := results[slotIndex].recipes[len(results[slotIndex].recipes)-1]
|
||||
results[slotIndex].recipes = append(results[slotIndex].recipes, last)
|
||||
}
|
||||
}
|
||||
|
||||
days := make([]ai.DayPlan, 7)
|
||||
for day := range 7 {
|
||||
days[day] = ai.DayPlan{
|
||||
Day: day + 1,
|
||||
Meals: []ai.MealEntry{
|
||||
{MealType: slots[0].mealType, Recipe: results[0].recipes[day]},
|
||||
{MealType: slots[1].mealType, Recipe: results[1].recipes[day]},
|
||||
{MealType: slots[2].mealType, Recipe: results[2].recipes[day]},
|
||||
},
|
||||
dayPlans := make([]ai.DayPlan, recipeCount)
|
||||
for dayIndex, dayOfWeek := range days {
|
||||
meals := make([]ai.MealEntry, len(mealTypes))
|
||||
for slotIndex, mealType := range mealTypes {
|
||||
meals[slotIndex] = ai.MealEntry{
|
||||
MealType: mealType,
|
||||
Recipe: results[slotIndex].recipes[dayIndex],
|
||||
}
|
||||
}
|
||||
dayPlans[dayIndex] = ai.DayPlan{Day: dayOfWeek, Meals: meals}
|
||||
}
|
||||
return days, nil
|
||||
return dayPlans, nil
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
@@ -116,124 +121,232 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Week string `json:"week"` // optional, defaults to current week
|
||||
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)
|
||||
|
||||
weekStart, err := ResolveWeekStart(body.Week)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, "invalid week parameter")
|
||||
// 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
|
||||
}
|
||||
|
||||
// Load user profile.
|
||||
u, err := h.userLoader.GetByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "load user for menu generation", "err", err)
|
||||
writeError(w, r, http.StatusInternalServerError, "failed to load user profile")
|
||||
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()))
|
||||
|
||||
// Attach pantry products.
|
||||
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
||||
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
|
||||
menuReq.AvailableProducts = products
|
||||
}
|
||||
|
||||
// Generate 7-day plan via Gemini.
|
||||
days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", err)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
h.fetchImages(r.Context(), days)
|
||||
|
||||
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.WarnContext(r.Context(), "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
|
||||
planItems, saveError := h.saveRecipes(r.Context(), days)
|
||||
if saveError != nil {
|
||||
writeError(w, r, http.StatusInternalServerError, "failed to save recipes")
|
||||
return
|
||||
}
|
||||
|
||||
// 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.ErrorContext(r.Context(), "save recipe for menu", "title", meal.Recipe.Title, "err", err)
|
||||
writeError(w, r, 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.ErrorContext(r.Context(), "save menu plan", "err", err)
|
||||
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
|
||||
}
|
||||
|
||||
// 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.WarnContext(r.Context(), "auto-generate shopping list", "err", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the freshly saved plan.
|
||||
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
|
||||
if err != nil || plan == nil {
|
||||
slog.ErrorContext(r.Context(), "load generated menu", "err", err, "plan_nil", plan == nil)
|
||||
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())
|
||||
|
||||
@@ -47,7 +47,15 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
|
||||
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
|
||||
WHERE mp.user_id = $1 AND mp.week_start::text = $2
|
||||
ORDER BY mi.day_of_week,
|
||||
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END`
|
||||
CASE mi.meal_type
|
||||
WHEN 'breakfast' THEN 1
|
||||
WHEN 'second_breakfast' THEN 2
|
||||
WHEN 'lunch' THEN 3
|
||||
WHEN 'afternoon_snack' THEN 4
|
||||
WHEN 'dinner' THEN 5
|
||||
WHEN 'snack' THEN 6
|
||||
ELSE 7
|
||||
END`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, userID, weekStart, lang)
|
||||
if err != nil {
|
||||
@@ -163,6 +171,43 @@ func (r *Repository) SaveMenuInTx(ctx context.Context, userID, weekStart string,
|
||||
return planID, nil
|
||||
}
|
||||
|
||||
// UpsertItemsInTx creates (or retrieves) the menu_plan for the given week and
|
||||
// upserts only the specified meal slots, leaving all other existing slots untouched.
|
||||
func (r *Repository) UpsertItemsInTx(ctx context.Context, userID, weekStart string, items []PlanItem) (string, error) {
|
||||
transaction, beginError := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if beginError != nil {
|
||||
return "", fmt.Errorf("begin tx: %w", beginError)
|
||||
}
|
||||
defer transaction.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var planID string
|
||||
upsertError := transaction.QueryRow(ctx, `
|
||||
INSERT INTO menu_plans (user_id, week_start)
|
||||
VALUES ($1, $2::date)
|
||||
ON CONFLICT (user_id, week_start) DO UPDATE SET created_at = now()
|
||||
RETURNING id`, userID, weekStart).Scan(&planID)
|
||||
if upsertError != nil {
|
||||
return "", fmt.Errorf("upsert menu_plan: %w", upsertError)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if _, insertError := transaction.Exec(ctx, `
|
||||
INSERT INTO menu_items (menu_plan_id, day_of_week, meal_type, recipe_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (menu_plan_id, day_of_week, meal_type)
|
||||
DO UPDATE SET recipe_id = EXCLUDED.recipe_id`,
|
||||
planID, item.DayOfWeek, item.MealType, item.RecipeID,
|
||||
); insertError != nil {
|
||||
return "", fmt.Errorf("upsert menu item day=%d meal=%s: %w", item.DayOfWeek, item.MealType, insertError)
|
||||
}
|
||||
}
|
||||
|
||||
if commitError := transaction.Commit(ctx); commitError != nil {
|
||||
return "", fmt.Errorf("commit tx: %w", commitError)
|
||||
}
|
||||
return planID, nil
|
||||
}
|
||||
|
||||
// UpdateItem replaces the recipe in a menu slot.
|
||||
func (r *Repository) UpdateItem(ctx context.Context, itemID, userID, recipeID string) error {
|
||||
tag, err := r.pool.Exec(ctx, `
|
||||
|
||||
Reference in New Issue
Block a user