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 'lunch' THEN 2 ELSE 3 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 } // 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 ingredients from all recipes in the plan. func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) { rows, err := r.pool.Query(ctx, ` SELECT ri.name, ri.amount, ri.unit_code, mi.meal_type FROM menu_items mi JOIN recipes rec ON rec.id = mi.recipe_id JOIN recipe_ingredients ri ON ri.recipe_id = rec.id WHERE mi.menu_plan_id = $1 ORDER BY ri.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 }