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>
371 lines
10 KiB
Go
371 lines
10 KiB
Go
package menu
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/food-ai/backend/internal/infra/locale"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ErrNotFound is returned when a menu item is not found for the user.
|
|
var ErrNotFound = errors.New("menu item not found")
|
|
|
|
// Repository handles persistence for menu plans, items, and shopping lists.
|
|
type Repository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewRepository creates a new Repository.
|
|
func NewRepository(pool *pgxpool.Pool) *Repository {
|
|
return &Repository{pool: pool}
|
|
}
|
|
|
|
// GetByWeek loads the full menu plan for the user and given Monday date (YYYY-MM-DD).
|
|
// Returns nil, nil when no plan exists for that week.
|
|
func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*MenuPlan, error) {
|
|
lang := locale.FromContext(ctx)
|
|
|
|
const q = `
|
|
SELECT mp.id, mp.week_start::text,
|
|
mi.id, mi.day_of_week, mi.meal_type,
|
|
rec.id,
|
|
COALESCE(dt.name, d.name),
|
|
COALESCE(d.image_url, ''),
|
|
rec.calories_per_serving,
|
|
rec.protein_per_serving,
|
|
rec.fat_per_serving,
|
|
rec.carbs_per_serving
|
|
FROM menu_plans mp
|
|
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id
|
|
LEFT JOIN recipes rec ON rec.id = mi.recipe_id
|
|
LEFT JOIN dishes d ON d.id = rec.dish_id
|
|
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 '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 {
|
|
return nil, fmt.Errorf("get menu by week: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var plan *MenuPlan
|
|
dayMap := map[int]*MenuDay{}
|
|
|
|
for rows.Next() {
|
|
var (
|
|
planID, planWeekStart string
|
|
itemID, mealType *string
|
|
dow *int
|
|
recipeID, title, imageURL *string
|
|
calPer, protPer, fatPer, carbPer *float64
|
|
)
|
|
if err := rows.Scan(
|
|
&planID, &planWeekStart,
|
|
&itemID, &dow, &mealType,
|
|
&recipeID, &title, &imageURL,
|
|
&calPer, &protPer, &fatPer, &carbPer,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan menu row: %w", err)
|
|
}
|
|
|
|
if plan == nil {
|
|
plan = &MenuPlan{ID: planID, WeekStart: planWeekStart}
|
|
}
|
|
|
|
if itemID == nil || dow == nil || mealType == nil {
|
|
continue
|
|
}
|
|
|
|
day, ok := dayMap[*dow]
|
|
if !ok {
|
|
day = &MenuDay{Day: *dow, Date: dayDate(planWeekStart, *dow)}
|
|
dayMap[*dow] = day
|
|
}
|
|
|
|
slot := MealSlot{ID: *itemID, MealType: *mealType}
|
|
if recipeID != nil && title != nil {
|
|
nutrition := NutritionInfo{
|
|
Calories: derefFloat(calPer),
|
|
ProteinG: derefFloat(protPer),
|
|
FatG: derefFloat(fatPer),
|
|
CarbsG: derefFloat(carbPer),
|
|
}
|
|
slot.Recipe = &MenuRecipe{
|
|
ID: *recipeID,
|
|
Title: *title,
|
|
ImageURL: derefStr(imageURL),
|
|
Nutrition: nutrition,
|
|
}
|
|
day.TotalCalories += nutrition.Calories
|
|
}
|
|
day.Meals = append(day.Meals, slot)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if plan == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Assemble days in order.
|
|
for dow := 1; dow <= 7; dow++ {
|
|
if d, ok := dayMap[dow]; ok {
|
|
plan.Days = append(plan.Days, *d)
|
|
}
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
// SaveMenuInTx upserts a menu_plan row, wipes previous menu_items, and inserts
|
|
// the new ones — all in a single transaction.
|
|
func (r *Repository) SaveMenuInTx(ctx context.Context, userID, weekStart string, items []PlanItem) (string, error) {
|
|
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return "", fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck
|
|
|
|
var planID string
|
|
err = tx.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 err != nil {
|
|
return "", fmt.Errorf("upsert menu_plan: %w", err)
|
|
}
|
|
|
|
if _, err = tx.Exec(ctx, `DELETE FROM menu_items WHERE menu_plan_id = $1`, planID); err != nil {
|
|
return "", fmt.Errorf("delete old menu items: %w", err)
|
|
}
|
|
|
|
for _, item := range items {
|
|
if _, err = tx.Exec(ctx, `
|
|
INSERT INTO menu_items (menu_plan_id, day_of_week, meal_type, recipe_id)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
planID, item.DayOfWeek, item.MealType, item.RecipeID,
|
|
); err != nil {
|
|
return "", fmt.Errorf("insert menu item: %w", err)
|
|
}
|
|
}
|
|
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return "", fmt.Errorf("commit tx: %w", err)
|
|
}
|
|
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, `
|
|
UPDATE menu_items mi
|
|
SET recipe_id = $3
|
|
FROM menu_plans mp
|
|
WHERE mi.id = $1 AND mp.id = mi.menu_plan_id AND mp.user_id = $2`,
|
|
itemID, userID, recipeID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update menu item: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteItem removes a menu slot.
|
|
func (r *Repository) DeleteItem(ctx context.Context, itemID, userID string) error {
|
|
tag, err := r.pool.Exec(ctx, `
|
|
DELETE FROM menu_items mi
|
|
USING menu_plans mp
|
|
WHERE mi.id = $1 AND mp.id = mi.menu_plan_id AND mp.user_id = $2`,
|
|
itemID, userID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("delete menu item: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertShoppingList stores the shopping list for a menu plan.
|
|
func (r *Repository) UpsertShoppingList(ctx context.Context, userID, planID string, items []ShoppingItem) error {
|
|
raw, err := json.Marshal(items)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal shopping items: %w", err)
|
|
}
|
|
_, err = r.pool.Exec(ctx, `
|
|
INSERT INTO shopping_lists (user_id, menu_plan_id, items)
|
|
VALUES ($1, $2, $3::jsonb)
|
|
ON CONFLICT (user_id, menu_plan_id) DO UPDATE
|
|
SET items = EXCLUDED.items, generated_at = now()`,
|
|
userID, planID, string(raw),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// GetShoppingList returns the shopping list for the user's plan.
|
|
func (r *Repository) GetShoppingList(ctx context.Context, userID, planID string) ([]ShoppingItem, error) {
|
|
var raw []byte
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT items FROM shopping_lists
|
|
WHERE user_id = $1 AND menu_plan_id = $2`,
|
|
userID, planID,
|
|
).Scan(&raw)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get shopping list: %w", err)
|
|
}
|
|
var items []ShoppingItem
|
|
if err := json.Unmarshal(raw, &items); err != nil {
|
|
return nil, fmt.Errorf("unmarshal shopping items: %w", err)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// ToggleShoppingItem flips the checked flag for the item at the given index.
|
|
func (r *Repository) ToggleShoppingItem(ctx context.Context, userID, planID string, index int, checked bool) error {
|
|
tag, err := r.pool.Exec(ctx, `
|
|
UPDATE shopping_lists
|
|
SET items = jsonb_set(items, ARRAY[$1::text, 'checked'], to_jsonb($2::boolean))
|
|
WHERE user_id = $3 AND menu_plan_id = $4`,
|
|
fmt.Sprintf("%d", index), checked, userID, planID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("toggle shopping item: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetPlanIDByWeek returns the menu_plan id for the user and given Monday date.
|
|
func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart string) (string, error) {
|
|
var id string
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT id FROM menu_plans WHERE user_id = $1 AND week_start::text = $2`,
|
|
userID, weekStart,
|
|
).Scan(&id)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("get plan id: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// GetIngredientsByPlan returns all products from all recipes in the plan.
|
|
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT rp.name, rp.amount, rp.unit_code, mi.meal_type
|
|
FROM menu_items mi
|
|
JOIN recipes rec ON rec.id = mi.recipe_id
|
|
JOIN recipe_products rp ON rp.recipe_id = rec.id
|
|
WHERE mi.menu_plan_id = $1
|
|
ORDER BY rp.sort_order`, planID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get ingredients by plan: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []ingredientRow
|
|
for rows.Next() {
|
|
var row ingredientRow
|
|
if err := rows.Scan(&row.Name, &row.Amount, &row.UnitCode, &row.MealType); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, row)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
type ingredientRow struct {
|
|
Name string
|
|
Amount float64
|
|
UnitCode *string
|
|
MealType string
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func dayDate(weekStart string, dow int) string {
|
|
t, err := time.Parse("2006-01-02", weekStart)
|
|
if err != nil {
|
|
return weekStart
|
|
}
|
|
return t.AddDate(0, 0, dow-1).Format("2006-01-02")
|
|
}
|
|
|
|
func derefStr(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
func derefFloat(f *float64) float64 {
|
|
if f == nil {
|
|
return 0
|
|
}
|
|
return *f
|
|
}
|