Files
food-ai/backend/internal/menu/repository.go
dbastrikin ea8e207a45 feat: implement Iteration 4 — menu planning, shopping list, diary
Backend:
- Migrations 007 (menu_plans, menu_items, shopping_lists) and
  008 (meal_diary)
- gemini/menu.go: GenerateMenu — 7-day × 3-meal plan via one Groq call
- internal/menu: model, repository (GetByWeek, SaveMenuInTx,
  shopping list CRUD), handler (GET/PUT/DELETE /menu,
  POST /ai/generate-menu, shopping list endpoints)
- internal/diary: model, repository, handler (GET/POST/DELETE /diary)
- Increase server WriteTimeout to 120s for long AI calls
- api_client.go: add patch() and postList() helpers

Flutter:
- shared/models: menu.dart, shopping_item.dart, diary_entry.dart
- features/menu: menu_service.dart, menu_provider.dart
  (MenuNotifier, ShoppingListNotifier, DiaryNotifier with family)
- MenuScreen: 7-day view, week nav, skeleton on generation,
  generate FAB with confirmation dialog
- ShoppingListScreen: items by category, optimistic checkbox toggle
- DiaryScreen: daily entries with swipe-to-delete, add-entry sheet
- Router: /menu/shopping-list and /menu/diary routes

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

307 lines
8.3 KiB
Go

package menu
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"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) {
const q = `
SELECT mp.id, mp.week_start,
mi.id, mi.day_of_week, mi.meal_type,
sr.id, sr.title, COALESCE(sr.image_url, ''), sr.nutrition
FROM menu_plans mp
LEFT JOIN menu_items mi ON mi.menu_plan_id = mp.id
LEFT JOIN saved_recipes sr ON sr.id = mi.recipe_id
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)
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
nutritionRaw []byte
)
if err := rows.Scan(
&planID, &planWeekStart,
&itemID, &dow, &mealType,
&recipeID, &title, &imageURL, &nutritionRaw,
); 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 {
var nutrition NutritionInfo
if len(nutritionRaw) > 0 {
_ = json.Unmarshal(nutritionRaw, &nutrition)
}
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 sr.ingredients, sr.nutrition, mi.meal_type
FROM menu_items mi
JOIN saved_recipes sr ON sr.id = mi.recipe_id
WHERE mi.menu_plan_id = $1`, planID)
if err != nil {
return nil, fmt.Errorf("get ingredients by plan: %w", err)
}
defer rows.Close()
var result []ingredientRow
for rows.Next() {
var ingredientsRaw, nutritionRaw []byte
var mealType string
if err := rows.Scan(&ingredientsRaw, &nutritionRaw, &mealType); err != nil {
return nil, err
}
result = append(result, ingredientRow{
IngredientsJSON: ingredientsRaw,
NutritionJSON: nutritionRaw,
MealType: mealType,
})
}
return result, rows.Err()
}
type ingredientRow struct {
IngredientsJSON []byte
NutritionJSON []byte
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
}