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>
This commit is contained in:
dbastrikin
2026-02-22 12:00:25 +02:00
parent 612a0eda60
commit ea8e207a45
22 changed files with 2926 additions and 12 deletions

View File

@@ -13,8 +13,10 @@ import (
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/config"
"github.com/food-ai/backend/internal/database"
"github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/pexels"
"github.com/food-ai/backend/internal/product"
@@ -112,6 +114,14 @@ func run() error {
savedRecipeRepo := savedrecipe.NewRepository(pool)
savedRecipeHandler := savedrecipe.NewHandler(savedRecipeRepo)
// Menu domain
menuRepo := menu.NewRepository(pool)
menuHandler := menu.NewHandler(menuRepo, geminiClient, pexelsClient, userRepo, productRepo, savedRecipeRepo)
// Diary domain
diaryRepo := diary.NewRepository(pool)
diaryHandler := diary.NewHandler(diaryRepo)
// Router
router := server.NewRouter(
pool,
@@ -122,6 +132,8 @@ func run() error {
ingredientHandler,
productHandler,
recognitionHandler,
menuHandler,
diaryHandler,
authMW,
cfg.AllowedOrigins,
)
@@ -130,7 +142,7 @@ func run() error {
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second, // menu generation can take ~60s
IdleTimeout: 60 * time.Second,
}

View File

@@ -0,0 +1,110 @@
package diary
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/middleware"
"github.com/go-chi/chi/v5"
)
// Handler handles diary endpoints.
type Handler struct {
repo *Repository
}
// NewHandler creates a new Handler.
func NewHandler(repo *Repository) *Handler {
return &Handler{repo: repo}
}
// GetByDate handles GET /diary?date=YYYY-MM-DD
func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
date := r.URL.Query().Get("date")
if date == "" {
writeError(w, http.StatusBadRequest, "date query parameter required (YYYY-MM-DD)")
return
}
entries, err := h.repo.ListByDate(r.Context(), userID, date)
if err != nil {
slog.Error("list diary by date", "err", err)
writeError(w, http.StatusInternalServerError, "failed to load diary")
return
}
if entries == nil {
entries = []*Entry{}
}
writeJSON(w, http.StatusOK, entries)
}
// Create handles POST /diary
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Date == "" || req.Name == "" || req.MealType == "" {
writeError(w, http.StatusBadRequest, "date, meal_type and name are required")
return
}
entry, err := h.repo.Create(r.Context(), userID, req)
if err != nil {
slog.Error("create diary entry", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create diary entry")
return
}
writeJSON(w, http.StatusCreated, entry)
}
// Delete handles DELETE /diary/{id}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
id := chi.URLParam(r, "id")
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
if err == ErrNotFound {
writeError(w, http.StatusNotFound, "diary entry not found")
return
}
slog.Error("delete diary entry", "err", err)
writeError(w, http.StatusInternalServerError, "failed to delete diary entry")
return
}
w.WriteHeader(http.StatusNoContent)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,33 @@
package diary
import "time"
// Entry is a single meal diary record.
type Entry struct {
ID string `json:"id"`
Date string `json:"date"` // YYYY-MM-DD
MealType string `json:"meal_type"`
Name string `json:"name"`
Portions float64 `json:"portions"`
Calories *float64 `json:"calories,omitempty"`
ProteinG *float64 `json:"protein_g,omitempty"`
FatG *float64 `json:"fat_g,omitempty"`
CarbsG *float64 `json:"carbs_g,omitempty"`
Source string `json:"source"`
RecipeID *string `json:"recipe_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CreateRequest is the body for POST /diary.
type CreateRequest struct {
Date string `json:"date"`
MealType string `json:"meal_type"`
Name string `json:"name"`
Portions float64 `json:"portions"`
Calories *float64 `json:"calories"`
ProteinG *float64 `json:"protein_g"`
FatG *float64 `json:"fat_g"`
CarbsG *float64 `json:"carbs_g"`
Source string `json:"source"`
RecipeID *string `json:"recipe_id"`
}

View File

@@ -0,0 +1,104 @@
package diary
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a diary entry does not exist for the user.
var ErrNotFound = errors.New("diary entry not found")
// Repository handles persistence for meal diary entries.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD).
func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, date::text, meal_type, name, portions,
calories, protein_g, fat_g, carbs_g,
source, recipe_id, created_at
FROM meal_diary
WHERE user_id = $1 AND date = $2::date
ORDER BY created_at ASC`, userID, date)
if err != nil {
return nil, fmt.Errorf("list diary: %w", err)
}
defer rows.Close()
var result []*Entry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, fmt.Errorf("scan diary entry: %w", err)
}
result = append(result, e)
}
return result, rows.Err()
}
// Create inserts a new diary entry and returns the stored record.
func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) {
portions := req.Portions
if portions <= 0 {
portions = 1
}
source := req.Source
if source == "" {
source = "manual"
}
row := r.pool.QueryRow(ctx, `
INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
calories, protein_g, fat_g, carbs_g, source, recipe_id)
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, date::text, meal_type, name, portions,
calories, protein_g, fat_g, carbs_g, source, recipe_id, created_at`,
userID, req.Date, req.MealType, req.Name, portions,
req.Calories, req.ProteinG, req.FatG, req.CarbsG,
source, req.RecipeID,
)
return scanEntry(row)
}
// Delete removes a diary entry for the given user.
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM meal_diary WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete diary entry: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// --- helpers ---
type scannable interface {
Scan(dest ...any) error
}
func scanEntry(s scannable) (*Entry, error) {
var e Entry
err := s.Scan(
&e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions,
&e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG,
&e.Source, &e.RecipeID, &e.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &e, err
}

View File

@@ -0,0 +1,168 @@
package gemini
import (
"context"
"fmt"
"strings"
)
// MenuRequest contains parameters for weekly menu generation.
type MenuRequest struct {
UserGoal string
DailyCalories int
Restrictions []string
CuisinePrefs []string
AvailableProducts []string
}
// DayPlan is the AI-generated plan for a single day.
type DayPlan struct {
Day int `json:"day"`
Meals []MealEntry `json:"meals"`
}
// MealEntry is a single meal within a day plan.
type MealEntry struct {
MealType string `json:"meal_type"` // breakfast | lunch | dinner
Recipe Recipe `json:"recipe"`
}
// GenerateMenu asks the model to plan 7 days × 3 meals.
// Returns exactly 7 DayPlan items on success.
func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, error) {
prompt := buildMenuPrompt(req)
messages := []map[string]string{
{"role": "user", "content": prompt},
}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
messages = append(messages, map[string]string{
"role": "user",
"content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО объект JSON без какого-либо текста до или после.",
})
}
text, err := c.generateContent(ctx, messages)
if err != nil {
return nil, err
}
days, err := parseMenuJSON(text)
if err != nil {
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
continue
}
for i := range days {
for j := range days[i].Meals {
days[i].Meals[j].Recipe.Nutrition.Approximate = true
}
}
return days, nil
}
return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr)
}
func buildMenuPrompt(req MenuRequest) string {
goalRu := map[string]string{
"weight_loss": "похудение",
"maintain": "поддержание веса",
"gain": "набор массы",
}
goal := goalRu[req.UserGoal]
if goal == "" {
goal = "поддержание веса"
}
restrictions := "нет"
if len(req.Restrictions) > 0 {
restrictions = strings.Join(req.Restrictions, ", ")
}
cuisines := "любые"
if len(req.CuisinePrefs) > 0 {
cuisines = strings.Join(req.CuisinePrefs, ", ")
}
calories := req.DailyCalories
if calories <= 0 {
calories = 2000
}
productsSection := ""
if len(req.AvailableProducts) > 0 {
productsSection = "\nПродукты в наличии (приоритет — скоро истекают ⚠):\n" +
strings.Join(req.AvailableProducts, "\n") +
"\nПо возможности используй эти продукты.\n"
}
return fmt.Sprintf(`Ты — диетолог-повар. Составь меню на 7 дней на русском языке.
Профиль пользователя:
- Цель: %s
- Дневная норма калорий: %d ккал (завтрак 25%%, обед 40%%, ужин 35%%)
- Ограничения: %s
- Предпочтения кухни: %s
%s
Требования:
- 3 приёма пищи в день: breakfast, lunch, dinner
- Не повторять рецепты
- КБЖУ на 1 порцию (приблизительно)
- Поле image_query — ТОЛЬКО на английском языке (для поиска фото)
Верни ТОЛЬКО валидный JSON без markdown:
{
"days": [
{
"day": 1,
"meals": [
{
"meal_type": "breakfast",
"recipe": {
"title": "Название",
"description": "2-3 предложения",
"cuisine": "russian|asian|european|mediterranean|american|other",
"difficulty": "easy|medium|hard",
"prep_time_min": 5,
"cook_time_min": 10,
"servings": 1,
"image_query": "oatmeal apple breakfast bowl",
"ingredients": [{"name": "Овсянка", "amount": 80, "unit": "г"}],
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
"tags": ["быстрый завтрак"],
"nutrition_per_serving": {
"calories": 320, "protein_g": 8, "fat_g": 6, "carbs_g": 58
}
}
},
{"meal_type": "lunch", "recipe": {...}},
{"meal_type": "dinner", "recipe": {...}}
]
}
]
}`, goal, calories, restrictions, cuisines, productsSection)
}
func parseMenuJSON(text string) ([]DayPlan, error) {
text = strings.TrimSpace(text)
if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
}
var wrapper struct {
Days []DayPlan `json:"days"`
}
if err := parseJSON(text, &wrapper); err != nil {
return nil, err
}
if len(wrapper.Days) == 0 {
return nil, fmt.Errorf("empty days array in response")
}
return wrapper.Days, nil
}

View File

@@ -0,0 +1,545 @@
package menu
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/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 persists a single recipe and returns the stored record.
type RecipeSaver interface {
Save(ctx context.Context, userID string, req savedrecipe.SaveRequest) (*savedrecipe.SavedRecipe, error)
}
// Handler handles menu and shopping-list endpoints.
type Handler struct {
repo *Repository
gemini *gemini.Client
pexels PhotoSearcher
userLoader UserLoader
productLister ProductLister
recipeSaver RecipeSaver
}
// NewHandler creates a new Handler.
func NewHandler(
repo *Repository,
geminiClient *gemini.Client,
pexels PhotoSearcher,
userLoader UserLoader,
productLister ProductLister,
recipeSaver RecipeSaver,
) *Handler {
return &Handler{
repo: repo,
gemini: geminiClient,
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, http.StatusUnauthorized, "unauthorized")
return
}
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN")
return
}
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
if err != nil {
slog.Error("get menu", "err", err)
writeError(w, 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
func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var body struct {
Week string `json:"week"` // optional, defaults to current week
}
_ = json.NewDecoder(r.Body).Decode(&body)
weekStart, err := resolveWeekStart(body.Week)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter")
return
}
// Load user profile.
u, err := h.userLoader.GetByID(r.Context(), userID)
if err != nil {
slog.Error("load user for menu generation", "err", err)
writeError(w, http.StatusInternalServerError, "failed to load user profile")
return
}
menuReq := buildMenuRequest(u)
// Attach pantry products.
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
menuReq.AvailableProducts = products
}
// Generate 7-day plan via Groq.
days, err := h.gemini.GenerateMenu(r.Context(), menuReq)
if err != nil {
slog.Error("generate menu", "user_id", userID, "err", err)
writeError(w, 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
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.Warn("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
}
// Save all 21 recipes to saved_recipes.
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 {
saved, err := h.recipeSaver.Save(r.Context(), userID, recipeToSaveRequest(meal.Recipe))
if err != nil {
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
writeError(w, http.StatusInternalServerError, "failed to save recipes")
return
}
refs = append(refs, savedRef{di, mi, saved.ID})
}
}
// 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.Error("save menu plan", "err", err)
writeError(w, 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.Warn("auto-generate shopping list", "err", err)
}
}
// Return the freshly saved plan.
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
if err != nil || plan == nil {
writeError(w, http.StatusInternalServerError, "failed to load generated menu")
return
}
writeJSON(w, http.StatusOK, plan)
}
// 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, 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, http.StatusBadRequest, "recipe_id required")
return
}
if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil {
if err == ErrNotFound {
writeError(w, http.StatusNotFound, "menu item not found")
return
}
slog.Error("update menu item", "err", err)
writeError(w, 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, 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, http.StatusNotFound, "menu item not found")
return
}
slog.Error("delete menu item", "err", err)
writeError(w, 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, 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, http.StatusBadRequest, "invalid week parameter")
return
}
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
if err != nil {
if err == ErrNotFound {
writeError(w, http.StatusNotFound, "no menu plan found for this week")
return
}
slog.Error("get plan id", "err", err)
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
return
}
items, err := h.buildShoppingList(r.Context(), planID)
if err != nil {
slog.Error("build shopping list", "err", err)
writeError(w, http.StatusInternalServerError, "failed to build shopping list")
return
}
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil {
slog.Error("upsert shopping list", "err", err)
writeError(w, 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, http.StatusUnauthorized, "unauthorized")
return
}
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
if err != nil {
writeError(w, 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, http.StatusInternalServerError, "failed to find menu plan")
return
}
items, err := h.repo.GetShoppingList(r.Context(), userID, planID)
if err != nil {
writeError(w, 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, 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, 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, http.StatusBadRequest, "invalid request body")
return
}
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter")
return
}
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
if err != nil {
writeError(w, http.StatusNotFound, "menu plan not found")
return
}
if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil {
slog.Error("toggle shopping item", "err", err)
writeError(w, 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{}
categories := map[string]string{} // name → category (from meal_type heuristic)
for _, row := range rows {
var ingredients []struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
}
if len(row.IngredientsJSON) > 0 {
if err := json.Unmarshal(row.IngredientsJSON, &ingredients); err != nil {
continue
}
}
for _, ing := range ingredients {
k := key{strings.ToLower(strings.TrimSpace(ing.Name)), ing.Unit}
totals[k] += ing.Amount
if _, ok := categories[k.name]; !ok {
categories[k.name] = "other"
}
}
}
items := make([]ShoppingItem, 0, len(totals))
for k, amount := range totals {
items = append(items, ShoppingItem{
Name: k.name,
Category: categories[k.name],
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) gemini.MenuRequest {
req := gemini.MenuRequest{DailyCalories: 2000}
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
}
func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest {
ingJSON, _ := json.Marshal(r.Ingredients)
stepsJSON, _ := json.Marshal(r.Steps)
tagsJSON, _ := json.Marshal(r.Tags)
nutritionJSON, _ := json.Marshal(r.Nutrition)
return savedrecipe.SaveRequest{
Title: r.Title,
Description: r.Description,
Cuisine: r.Cuisine,
Difficulty: r.Difficulty,
PrepTimeMin: r.PrepTimeMin,
CookTimeMin: r.CookTimeMin,
Servings: r.Servings,
ImageURL: r.ImageURL,
Ingredients: ingJSON,
Steps: stepsJSON,
Tags: tagsJSON,
Nutrition: nutritionJSON,
Source: "menu",
}
}
// 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"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,56 @@
package menu
// MenuPlan is a weekly meal plan for a user.
type MenuPlan struct {
ID string `json:"id"`
WeekStart string `json:"week_start"` // YYYY-MM-DD (Monday)
Days []MenuDay `json:"days"`
}
// MenuDay groups three meal slots for one calendar day.
type MenuDay struct {
Day int `json:"day"` // 1=Monday … 7=Sunday
Date string `json:"date"`
Meals []MealSlot `json:"meals"`
TotalCalories float64 `json:"total_calories"`
}
// MealSlot holds a single meal within a day.
type MealSlot struct {
ID string `json:"id"`
MealType string `json:"meal_type"` // breakfast | lunch | dinner
Recipe *MenuRecipe `json:"recipe,omitempty"`
}
// MenuRecipe is a thin projection of a saved recipe used in the menu view.
type MenuRecipe struct {
ID string `json:"id"`
Title string `json:"title"`
ImageURL string `json:"image_url"`
Nutrition NutritionInfo `json:"nutrition_per_serving"`
}
// NutritionInfo holds macronutrient data.
type NutritionInfo struct {
Calories float64 `json:"calories"`
ProteinG float64 `json:"protein_g"`
FatG float64 `json:"fat_g"`
CarbsG float64 `json:"carbs_g"`
}
// PlanItem is the input needed to create one menu_items row.
type PlanItem struct {
DayOfWeek int
MealType string
RecipeID string
}
// ShoppingItem is one entry in the shopping list.
type ShoppingItem struct {
Name string `json:"name"`
Category string `json:"category"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
Checked bool `json:"checked"`
InStock float64 `json:"in_stock"`
}

View File

@@ -0,0 +1,306 @@
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
}

View File

@@ -5,7 +5,9 @@ import (
"net/http"
"github.com/food-ai/backend/internal/auth"
"github.com/food-ai/backend/internal/diary"
"github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recognition"
@@ -25,6 +27,8 @@ func NewRouter(
ingredientHandler *ingredient.Handler,
productHandler *product.Handler,
recognitionHandler *recognition.Handler,
menuHandler *menu.Handler,
diaryHandler *diary.Handler,
authMiddleware func(http.Handler) http.Handler,
allowedOrigins []string,
) *chi.Mux {
@@ -44,16 +48,12 @@ func NewRouter(
r.Post("/logout", authHandler.Logout)
})
// Public search (still requires auth to prevent scraping)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/ingredients/search", ingredientHandler.Search)
})
// Protected
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/ingredients/search", ingredientHandler.Search)
r.Get("/profile", userHandler.Get)
r.Put("/profile", userHandler.Update)
@@ -74,10 +74,29 @@ func NewRouter(
r.Delete("/{id}", productHandler.Delete)
})
r.Route("/menu", func(r chi.Router) {
r.Get("/", menuHandler.GetMenu)
r.Put("/items/{id}", menuHandler.UpdateMenuItem)
r.Delete("/items/{id}", menuHandler.DeleteMenuItem)
})
r.Route("/shopping-list", func(r chi.Router) {
r.Get("/", menuHandler.GetShoppingList)
r.Post("/generate", menuHandler.GenerateShoppingList)
r.Patch("/items/{index}/check", menuHandler.ToggleShoppingItem)
})
r.Route("/diary", func(r chi.Router) {
r.Get("/", diaryHandler.GetByDate)
r.Post("/", diaryHandler.Create)
r.Delete("/{id}", diaryHandler.Delete)
})
r.Route("/ai", func(r chi.Router) {
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
r.Post("/recognize-dish", recognitionHandler.RecognizeDish)
r.Post("/generate-menu", menuHandler.GenerateMenu)
})
})

View File

@@ -0,0 +1,35 @@
-- +goose Up
CREATE TABLE menu_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
week_start DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, week_start)
);
CREATE TABLE menu_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
menu_plan_id UUID NOT NULL REFERENCES menu_plans(id) ON DELETE CASCADE,
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner')),
recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL,
recipe_data JSONB,
UNIQUE(menu_plan_id, day_of_week, meal_type)
);
-- Stores the generated shopping list for a menu plan.
-- items is a JSONB array of {name, category, amount, unit, checked, in_stock}.
CREATE TABLE shopping_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
menu_plan_id UUID REFERENCES menu_plans(id) ON DELETE CASCADE,
items JSONB NOT NULL DEFAULT '[]',
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, menu_plan_id)
);
-- +goose Down
DROP TABLE shopping_lists;
DROP TABLE menu_items;
DROP TABLE menu_plans;

View File

@@ -0,0 +1,22 @@
-- +goose Up
CREATE TABLE meal_diary (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
meal_type TEXT NOT NULL,
name TEXT NOT NULL,
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
calories DECIMAL(8,2),
protein_g DECIMAL(8,2),
fat_g DECIMAL(8,2),
carbs_g DECIMAL(8,2),
source TEXT NOT NULL DEFAULT 'manual',
recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_meal_diary_user_date ON meal_diary(user_id, date);
-- +goose Down
DROP TABLE meal_diary;