feat: slim meal_diary — derive name and nutrition from dish/recipe
Remove denormalized columns (name, calories, protein_g, fat_g, carbs_g) from meal_diary. Name is now resolved via JOIN with dishes/dish_translations; macros are computed as recipe.*_per_serving * portions at query time. - Add dish.Repository.FindOrCreateRecipe: finds or creates a minimal recipe stub seeded with AI-estimated macros - recognition/handler: resolve recipe_id synchronously per candidate; simplify enrichDishInBackground to translations-only - diary/handler: accept dish_id OR name; always resolve recipe_id via FindOrCreateRecipe before INSERT - diary/entity: DishID is now non-nullable string; CreateRequest drops macros - diary/repository: ListByDate and Create use JOIN to return computed macros - ai/types: add RecipeID field to DishCandidate - Update tests and wire_gen accordingly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,11 +54,11 @@ func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, err
|
|||||||
ingredientRepository := ingredient.NewRepository(pool)
|
ingredientRepository := ingredient.NewRepository(pool)
|
||||||
ingredientHandler := ingredient.NewHandler(ingredientRepository)
|
ingredientHandler := ingredient.NewHandler(ingredientRepository)
|
||||||
productHandler := product.NewHandler(productRepository)
|
productHandler := product.NewHandler(productRepository)
|
||||||
recognitionHandler := recognition.NewHandler(client, ingredientRepository)
|
recognitionHandler := recognition.NewHandler(client, ingredientRepository, dishRepository)
|
||||||
menuRepository := menu.NewRepository(pool)
|
menuRepository := menu.NewRepository(pool)
|
||||||
menuHandler := menu.NewHandler(menuRepository, client, pexelsClient, repository, productRepository, dishRepository)
|
menuHandler := menu.NewHandler(menuRepository, client, pexelsClient, repository, productRepository, dishRepository)
|
||||||
diaryRepository := diary.NewRepository(pool)
|
diaryRepository := diary.NewRepository(pool)
|
||||||
diaryHandler := diary.NewHandler(diaryRepository)
|
diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository)
|
||||||
homeHandler := home.NewHandler(pool)
|
homeHandler := home.NewHandler(pool)
|
||||||
dishHandler := dish.NewHandler(dishRepository)
|
dishHandler := dish.NewHandler(dishRepository)
|
||||||
recipeRepository := recipe.NewRepository(pool)
|
recipeRepository := recipe.NewRepository(pool)
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ type ReceiptResult struct {
|
|||||||
|
|
||||||
// DishCandidate is a single dish recognition candidate with estimated nutrition.
|
// DishCandidate is a single dish recognition candidate with estimated nutrition.
|
||||||
type DishCandidate struct {
|
type DishCandidate struct {
|
||||||
|
DishID *string `json:"dish_id,omitempty"`
|
||||||
|
RecipeID *string `json:"recipe_id,omitempty"`
|
||||||
DishName string `json:"dish_name"`
|
DishName string `json:"dish_name"`
|
||||||
WeightGrams int `json:"weight_grams"`
|
WeightGrams int `json:"weight_grams"`
|
||||||
Calories float64 `json:"calories"`
|
Calories float64 `json:"calories"`
|
||||||
|
|||||||
97
backend/internal/adapters/openai/dish.go
Normal file
97
backend/internal/adapters/openai/dish.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/adapters/ai"
|
||||||
|
"github.com/food-ai/backend/internal/infra/locale"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateRecipeForDish generates a single English recipe for a named dish.
|
||||||
|
func (c *Client) GenerateRecipeForDish(ctx context.Context, dishName string) (*ai.Recipe, error) {
|
||||||
|
prompt := fmt.Sprintf(`You are a chef and nutritionist.
|
||||||
|
Generate exactly 1 recipe for the dish "%s" in English.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Include accurate macros per serving
|
||||||
|
- Total cooking time: max 60 minutes
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- All text fields MUST be in English.
|
||||||
|
- The "image_query" field MUST be in English.
|
||||||
|
|
||||||
|
Return ONLY a valid JSON array with exactly one element, no markdown:
|
||||||
|
[{"title":"...","description":"...","cuisine":"russian|asian|european|mediterranean|american|other","difficulty":"easy|medium|hard","prep_time_min":10,"cook_time_min":20,"servings":2,"image_query":"...","ingredients":[{"name":"...","amount":100,"unit":"..."}],"steps":[{"number":1,"description":"...","timer_seconds":null}],"tags":["..."],"nutrition_per_serving":{"calories":400,"protein_g":30,"fat_g":10,"carbs_g":40}}]`,
|
||||||
|
dishName)
|
||||||
|
|
||||||
|
var lastError error
|
||||||
|
for attempt := range maxRetries {
|
||||||
|
messages := []map[string]string{{"role": "user", "content": prompt}}
|
||||||
|
if attempt > 0 {
|
||||||
|
messages = append(messages, map[string]string{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Previous response was not valid JSON. Return ONLY a JSON array with no text before or after.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
text, generateError := c.generateContent(ctx, messages)
|
||||||
|
if generateError != nil {
|
||||||
|
return nil, generateError
|
||||||
|
}
|
||||||
|
recipes, parseError := parseRecipesJSON(text)
|
||||||
|
if parseError != nil {
|
||||||
|
lastError = parseError
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(recipes) == 0 {
|
||||||
|
lastError = fmt.Errorf("empty recipes array")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recipes[0].Nutrition.Approximate = true
|
||||||
|
return &recipes[0], nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("generate recipe for dish %q after %d attempts: %w", dishName, maxRetries, lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslateDishName translates a dish name into all supported languages (except English)
|
||||||
|
// in a single AI call. Returns a map of lang-code → translated name.
|
||||||
|
func (c *Client) TranslateDishName(ctx context.Context, name string) (map[string]string, error) {
|
||||||
|
var langLines []string
|
||||||
|
for _, language := range locale.Languages {
|
||||||
|
if language.Code != "en" {
|
||||||
|
langLines = append(langLines, fmt.Sprintf(`"%s": "%s"`, language.Code, language.EnglishName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(langLines) == 0 {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`Translate the dish name "%s" into the following languages.
|
||||||
|
Return ONLY a valid JSON object mapping language code to translated name, no markdown:
|
||||||
|
{%s}
|
||||||
|
Fill each value with the natural translation of the dish name in that language.`,
|
||||||
|
name, strings.Join(langLines, ", "))
|
||||||
|
|
||||||
|
text, generateError := c.generateContent(ctx, []map[string]string{
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
})
|
||||||
|
if generateError != nil {
|
||||||
|
return nil, generateError
|
||||||
|
}
|
||||||
|
|
||||||
|
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 translations map[string]string
|
||||||
|
if parseError := json.Unmarshal([]byte(text), &translations); parseError != nil {
|
||||||
|
return nil, fmt.Errorf("parse translations for %q: %w", name, parseError)
|
||||||
|
}
|
||||||
|
return translations, nil
|
||||||
|
}
|
||||||
@@ -7,14 +7,14 @@ type Entry struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Date string `json:"date"` // YYYY-MM-DD
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
MealType string `json:"meal_type"`
|
MealType string `json:"meal_type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"` // from dishes JOIN
|
||||||
Portions float64 `json:"portions"`
|
Portions float64 `json:"portions"`
|
||||||
Calories *float64 `json:"calories,omitempty"`
|
Calories *float64 `json:"calories,omitempty"` // recipe.calories_per_serving * portions
|
||||||
ProteinG *float64 `json:"protein_g,omitempty"`
|
ProteinG *float64 `json:"protein_g,omitempty"`
|
||||||
FatG *float64 `json:"fat_g,omitempty"`
|
FatG *float64 `json:"fat_g,omitempty"`
|
||||||
CarbsG *float64 `json:"carbs_g,omitempty"`
|
CarbsG *float64 `json:"carbs_g,omitempty"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
DishID *string `json:"dish_id,omitempty"`
|
DishID string `json:"dish_id"`
|
||||||
RecipeID *string `json:"recipe_id,omitempty"`
|
RecipeID *string `json:"recipe_id,omitempty"`
|
||||||
PortionG *float64 `json:"portion_g,omitempty"`
|
PortionG *float64 `json:"portion_g,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -24,12 +24,8 @@ type Entry struct {
|
|||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
MealType string `json:"meal_type"`
|
MealType string `json:"meal_type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"` // input-only; used if DishID is nil
|
||||||
Portions float64 `json:"portions"`
|
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"`
|
Source string `json:"source"`
|
||||||
DishID *string `json:"dish_id"`
|
DishID *string `json:"dish_id"`
|
||||||
RecipeID *string `json:"recipe_id"`
|
RecipeID *string `json:"recipe_id"`
|
||||||
|
|||||||
@@ -17,14 +17,26 @@ type DiaryRepository interface {
|
|||||||
Delete(ctx context.Context, id, userID string) error
|
Delete(ctx context.Context, id, userID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DishRepository is the subset of dish.Repository used by Handler to resolve dish IDs.
|
||||||
|
type DishRepository interface {
|
||||||
|
FindOrCreate(ctx context.Context, name string) (string, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecipeRepository is the subset of dish.Repository used by Handler to resolve recipe IDs.
|
||||||
|
type RecipeRepository interface {
|
||||||
|
FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Handler handles diary endpoints.
|
// Handler handles diary endpoints.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
repo DiaryRepository
|
repo DiaryRepository
|
||||||
|
dishRepo DishRepository
|
||||||
|
recipeRepo RecipeRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new Handler.
|
// NewHandler creates a new Handler.
|
||||||
func NewHandler(repo DiaryRepository) *Handler {
|
func NewHandler(repo DiaryRepository, dishRepo DishRepository, recipeRepo RecipeRepository) *Handler {
|
||||||
return &Handler{repo: repo}
|
return &Handler{repo: repo, dishRepo: dishRepo, recipeRepo: recipeRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByDate handles GET /diary?date=YYYY-MM-DD
|
// GetByDate handles GET /diary?date=YYYY-MM-DD
|
||||||
@@ -41,9 +53,9 @@ func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := h.repo.ListByDate(r.Context(), userID, date)
|
entries, listError := h.repo.ListByDate(r.Context(), userID, date)
|
||||||
if err != nil {
|
if listError != nil {
|
||||||
slog.Error("list diary by date", "err", err)
|
slog.Error("list diary by date", "err", listError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to load diary")
|
writeError(w, http.StatusInternalServerError, "failed to load diary")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -62,18 +74,42 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req CreateRequest
|
var req CreateRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if decodeError := json.NewDecoder(r.Body).Decode(&req); decodeError != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Date == "" || req.Name == "" || req.MealType == "" {
|
if req.Date == "" || req.MealType == "" {
|
||||||
writeError(w, http.StatusBadRequest, "date, meal_type and name are required")
|
writeError(w, http.StatusBadRequest, "date and meal_type are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DishID == nil && req.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "dish_id or name is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := h.repo.Create(r.Context(), userID, req)
|
if req.DishID == nil {
|
||||||
if err != nil {
|
dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name)
|
||||||
slog.Error("create diary entry", "err", err)
|
if resolveError != nil {
|
||||||
|
slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to resolve dish")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.DishID = &dishID
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.RecipeID == nil {
|
||||||
|
recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0)
|
||||||
|
if recipeError != nil {
|
||||||
|
slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to resolve recipe")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.RecipeID = &recipeID
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, createError := h.repo.Create(r.Context(), userID, req)
|
||||||
|
if createError != nil {
|
||||||
|
slog.Error("create diary entry", "err", createError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create diary entry")
|
writeError(w, http.StatusInternalServerError, "failed to create diary entry")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -89,12 +125,12 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
|
if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil {
|
||||||
if err == ErrNotFound {
|
if deleteError == ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "diary entry not found")
|
writeError(w, http.StatusNotFound, "diary entry not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("delete diary entry", "err", err)
|
slog.Error("delete diary entry", "err", deleteError)
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete diary entry")
|
writeError(w, http.StatusInternalServerError, "failed to delete diary entry")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,30 @@ import (
|
|||||||
"github.com/food-ai/backend/internal/domain/diary"
|
"github.com/food-ai/backend/internal/domain/diary"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MockDishRepository is a test double implementing diary.DishRepository.
|
||||||
|
type MockDishRepository struct {
|
||||||
|
FindOrCreateFn func(ctx context.Context, name string) (string, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDishRepository) FindOrCreate(ctx context.Context, name string) (string, bool, error) {
|
||||||
|
if m.FindOrCreateFn != nil {
|
||||||
|
return m.FindOrCreateFn(ctx, name)
|
||||||
|
}
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockRecipeRepository is a test double implementing diary.RecipeRepository.
|
||||||
|
type MockRecipeRepository struct {
|
||||||
|
FindOrCreateRecipeFn func(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockRecipeRepository) FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) {
|
||||||
|
if m.FindOrCreateRecipeFn != nil {
|
||||||
|
return m.FindOrCreateRecipeFn(ctx, dishID, calories, proteinG, fatG, carbsG)
|
||||||
|
}
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MockDiaryRepository is a test double implementing diary.DiaryRepository.
|
// MockDiaryRepository is a test double implementing diary.DiaryRepository.
|
||||||
type MockDiaryRepository struct {
|
type MockDiaryRepository struct {
|
||||||
ListByDateFn func(ctx context.Context, userID, date string) ([]*diary.Entry, error)
|
ListByDateFn func(ctx context.Context, userID, date string) ([]*diary.Entry, error)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/infra/locale"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
@@ -23,14 +24,24 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD).
|
// ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD).
|
||||||
|
// Dish name and macros are computed via JOIN with dishes and recipes.
|
||||||
func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) {
|
func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, date::text, meal_type, name, portions,
|
SELECT
|
||||||
calories, protein_g, fat_g, carbs_g,
|
md.id, md.date::text, md.meal_type, md.portions,
|
||||||
source, dish_id, recipe_id, portion_g, created_at
|
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at,
|
||||||
FROM meal_diary
|
COALESCE(dt.name, d.name) AS dish_name,
|
||||||
WHERE user_id = $1 AND date = $2::date
|
r.calories_per_serving * md.portions,
|
||||||
ORDER BY created_at ASC`, userID, date)
|
r.protein_per_serving * md.portions,
|
||||||
|
r.fat_per_serving * md.portions,
|
||||||
|
r.carbs_per_serving * md.portions
|
||||||
|
FROM meal_diary md
|
||||||
|
JOIN dishes d ON d.id = md.dish_id
|
||||||
|
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
|
||||||
|
LEFT JOIN recipes r ON r.id = md.recipe_id
|
||||||
|
WHERE md.user_id = $1 AND md.date = $2::date
|
||||||
|
ORDER BY md.created_at ASC`, userID, date, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list diary: %w", err)
|
return nil, fmt.Errorf("list diary: %w", err)
|
||||||
}
|
}
|
||||||
@@ -38,17 +49,18 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En
|
|||||||
|
|
||||||
var result []*Entry
|
var result []*Entry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
e, err := scanEntry(rows)
|
entry, scanError := scanEntry(rows)
|
||||||
if err != nil {
|
if scanError != nil {
|
||||||
return nil, fmt.Errorf("scan diary entry: %w", err)
|
return nil, fmt.Errorf("scan diary entry: %w", scanError)
|
||||||
}
|
}
|
||||||
result = append(result, e)
|
result = append(result, entry)
|
||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create inserts a new diary entry and returns the stored record.
|
// Create inserts a new diary entry and returns the stored record (with computed macros).
|
||||||
func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) {
|
func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
portions := req.Portions
|
portions := req.Portions
|
||||||
if portions <= 0 {
|
if portions <= 0 {
|
||||||
portions = 1
|
portions = 1
|
||||||
@@ -58,25 +70,40 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
|
|||||||
source = "manual"
|
source = "manual"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var entryID string
|
||||||
|
insertError := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g)
|
||||||
|
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG,
|
||||||
|
).Scan(&entryID)
|
||||||
|
if insertError != nil {
|
||||||
|
return nil, fmt.Errorf("insert diary entry: %w", insertError)
|
||||||
|
}
|
||||||
|
|
||||||
row := r.pool.QueryRow(ctx, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
|
SELECT
|
||||||
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g)
|
md.id, md.date::text, md.meal_type, md.portions,
|
||||||
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at,
|
||||||
RETURNING id, date::text, meal_type, name, portions,
|
COALESCE(dt.name, d.name) AS dish_name,
|
||||||
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g, created_at`,
|
r.calories_per_serving * md.portions,
|
||||||
userID, req.Date, req.MealType, req.Name, portions,
|
r.protein_per_serving * md.portions,
|
||||||
req.Calories, req.ProteinG, req.FatG, req.CarbsG,
|
r.fat_per_serving * md.portions,
|
||||||
source, req.DishID, req.RecipeID, req.PortionG,
|
r.carbs_per_serving * md.portions
|
||||||
)
|
FROM meal_diary md
|
||||||
|
JOIN dishes d ON d.id = md.dish_id
|
||||||
|
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
|
||||||
|
LEFT JOIN recipes r ON r.id = md.recipe_id
|
||||||
|
WHERE md.id = $1`, entryID, lang)
|
||||||
return scanEntry(row)
|
return scanEntry(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a diary entry for the given user.
|
// Delete removes a diary entry for the given user.
|
||||||
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
|
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
|
||||||
tag, err := r.pool.Exec(ctx,
|
tag, deleteError := r.pool.Exec(ctx,
|
||||||
`DELETE FROM meal_diary WHERE id = $1 AND user_id = $2`, id, userID)
|
`DELETE FROM meal_diary WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
if err != nil {
|
if deleteError != nil {
|
||||||
return fmt.Errorf("delete diary entry: %w", err)
|
return fmt.Errorf("delete diary entry: %w", deleteError)
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
@@ -91,14 +118,15 @@ type scannable interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanEntry(s scannable) (*Entry, error) {
|
func scanEntry(s scannable) (*Entry, error) {
|
||||||
var e Entry
|
var entry Entry
|
||||||
err := s.Scan(
|
scanError := s.Scan(
|
||||||
&e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions,
|
&entry.ID, &entry.Date, &entry.MealType, &entry.Portions,
|
||||||
&e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG,
|
&entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.CreatedAt,
|
||||||
&e.Source, &e.DishID, &e.RecipeID, &e.PortionG, &e.CreatedAt,
|
&entry.Name,
|
||||||
|
&entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG,
|
||||||
)
|
)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(scanError, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return &e, err
|
return &entry, scanError
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,125 @@ func (r *Repository) List(ctx context.Context) ([]*Dish, error) {
|
|||||||
return dishes, nil
|
return dishes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindOrCreate returns the dish ID and whether it was newly created.
|
||||||
|
// Looks up by case-insensitive name match; creates a minimal dish row if not found.
|
||||||
|
func (r *Repository) FindOrCreate(ctx context.Context, name string) (id string, created bool, err error) {
|
||||||
|
queryError := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM dishes WHERE LOWER(name) = LOWER($1) LIMIT 1`, name,
|
||||||
|
).Scan(&id)
|
||||||
|
if queryError == nil {
|
||||||
|
return id, false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(queryError, pgx.ErrNoRows) {
|
||||||
|
return "", false, fmt.Errorf("find dish %q: %w", name, queryError)
|
||||||
|
}
|
||||||
|
insertError := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO dishes (name) VALUES ($1) RETURNING id`, name,
|
||||||
|
).Scan(&id)
|
||||||
|
if insertError != nil {
|
||||||
|
return "", false, fmt.Errorf("create dish %q: %w", name, insertError)
|
||||||
|
}
|
||||||
|
return id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindOrCreateRecipe returns the recipe ID for the given dish, creating a minimal stub if none exists.
|
||||||
|
// Pass zeros for all nutrition params when no estimates are available.
|
||||||
|
func (r *Repository) FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) {
|
||||||
|
var recipeID string
|
||||||
|
findError := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM recipes WHERE dish_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
||||||
|
dishID,
|
||||||
|
).Scan(&recipeID)
|
||||||
|
if findError == nil {
|
||||||
|
return recipeID, false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(findError, pgx.ErrNoRows) {
|
||||||
|
return "", false, fmt.Errorf("find recipe for dish: %w", findError)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertError := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO recipes (dish_id, source, servings, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving)
|
||||||
|
VALUES ($1, 'ai', 1, $2, $3, $4, $5)
|
||||||
|
RETURNING id`,
|
||||||
|
dishID, nullableFloat(calories), nullableFloat(proteinG), nullableFloat(fatG), nullableFloat(carbsG),
|
||||||
|
).Scan(&recipeID)
|
||||||
|
if insertError != nil {
|
||||||
|
return "", false, fmt.Errorf("create recipe stub for dish: %w", insertError)
|
||||||
|
}
|
||||||
|
return recipeID, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertTranslation inserts or updates the name translation for a dish in a given language.
|
||||||
|
func (r *Repository) UpsertTranslation(ctx context.Context, dishID, lang, name string) error {
|
||||||
|
_, upsertError := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO dish_translations (dish_id, lang, name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (dish_id, lang) DO UPDATE SET name = EXCLUDED.name`,
|
||||||
|
dishID, lang, name,
|
||||||
|
)
|
||||||
|
if upsertError != nil {
|
||||||
|
return fmt.Errorf("upsert dish translation %s/%s: %w", dishID, lang, upsertError)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRecipe inserts a recipe with its ingredients and steps for an existing dish.
|
||||||
|
func (r *Repository) AddRecipe(ctx context.Context, dishID string, req CreateRequest) (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
|
||||||
|
|
||||||
|
source := req.Source
|
||||||
|
if source == "" {
|
||||||
|
source = "ai"
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipeID string
|
||||||
|
insertError := transaction.QueryRow(ctx, `
|
||||||
|
INSERT INTO recipes (dish_id, source, difficulty, prep_time_min, cook_time_min, servings,
|
||||||
|
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id`,
|
||||||
|
dishID, source,
|
||||||
|
nullableStr(req.Difficulty), nullableInt(req.PrepTimeMin), nullableInt(req.CookTimeMin), nullableInt(req.Servings),
|
||||||
|
nullableFloat(req.Calories), nullableFloat(req.Protein), nullableFloat(req.Fat), nullableFloat(req.Carbs),
|
||||||
|
).Scan(&recipeID)
|
||||||
|
if insertError != nil {
|
||||||
|
return "", fmt.Errorf("insert recipe for dish %s: %w", dishID, insertError)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, ingredient := range req.Ingredients {
|
||||||
|
if _, ingredientError := transaction.Exec(ctx, `
|
||||||
|
INSERT INTO recipe_ingredients (recipe_id, name, amount, unit_code, is_optional, sort_order)
|
||||||
|
VALUES ($1, $2, $3, NULLIF($4,''), $5, $6)`,
|
||||||
|
recipeID, ingredient.Name, ingredient.Amount, ingredient.Unit, ingredient.IsOptional, i,
|
||||||
|
); ingredientError != nil {
|
||||||
|
return "", fmt.Errorf("insert ingredient %d: %w", i, ingredientError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range req.Steps {
|
||||||
|
stepNumber := step.Number
|
||||||
|
if stepNumber <= 0 {
|
||||||
|
stepNumber = 1
|
||||||
|
}
|
||||||
|
if _, stepError := transaction.Exec(ctx, `
|
||||||
|
INSERT INTO recipe_steps (recipe_id, step_number, timer_seconds, description)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
recipeID, stepNumber, step.TimerSeconds, step.Description,
|
||||||
|
); stepError != nil {
|
||||||
|
return "", fmt.Errorf("insert step %d: %w", stepNumber, stepError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if commitError := transaction.Commit(ctx); commitError != nil {
|
||||||
|
return "", fmt.Errorf("commit: %w", commitError)
|
||||||
|
}
|
||||||
|
return recipeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create creates a new dish + recipe row from a CreateRequest.
|
// Create creates a new dish + recipe row from a CreateRequest.
|
||||||
// Returns the recipe ID to be used in menu_items or user_saved_recipes.
|
// Returns the recipe ID to be used in menu_items or user_saved_recipes.
|
||||||
func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) {
|
func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) {
|
||||||
|
|||||||
@@ -9,11 +9,20 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/adapters/ai"
|
"github.com/food-ai/backend/internal/adapters/ai"
|
||||||
|
"github.com/food-ai/backend/internal/domain/dish"
|
||||||
|
"github.com/food-ai/backend/internal/domain/ingredient"
|
||||||
"github.com/food-ai/backend/internal/infra/locale"
|
"github.com/food-ai/backend/internal/infra/locale"
|
||||||
"github.com/food-ai/backend/internal/infra/middleware"
|
"github.com/food-ai/backend/internal/infra/middleware"
|
||||||
"github.com/food-ai/backend/internal/domain/ingredient"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DishRepository is the subset of dish.Repository used by this handler.
|
||||||
|
type DishRepository interface {
|
||||||
|
FindOrCreate(ctx context.Context, name string) (string, bool, error)
|
||||||
|
FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error)
|
||||||
|
UpsertTranslation(ctx context.Context, dishID, lang, name string) error
|
||||||
|
AddRecipe(ctx context.Context, dishID string, req dish.CreateRequest) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
// IngredientRepository is the subset of ingredient.Repository used by this handler.
|
// IngredientRepository is the subset of ingredient.Repository used by this handler.
|
||||||
type IngredientRepository interface {
|
type IngredientRepository interface {
|
||||||
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
|
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
|
||||||
@@ -28,17 +37,20 @@ type Recognizer interface {
|
|||||||
RecognizeProducts(ctx context.Context, imageBase64, mimeType, lang string) ([]ai.RecognizedItem, error)
|
RecognizeProducts(ctx context.Context, imageBase64, mimeType, lang string) ([]ai.RecognizedItem, error)
|
||||||
RecognizeDish(ctx context.Context, imageBase64, mimeType, lang string) (*ai.DishResult, error)
|
RecognizeDish(ctx context.Context, imageBase64, mimeType, lang string) (*ai.DishResult, error)
|
||||||
ClassifyIngredient(ctx context.Context, name string) (*ai.IngredientClassification, error)
|
ClassifyIngredient(ctx context.Context, name string) (*ai.IngredientClassification, error)
|
||||||
|
GenerateRecipeForDish(ctx context.Context, dishName string) (*ai.Recipe, error)
|
||||||
|
TranslateDishName(ctx context.Context, name string) (map[string]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler handles POST /ai/* recognition endpoints.
|
// Handler handles POST /ai/* recognition endpoints.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
recognizer Recognizer
|
recognizer Recognizer
|
||||||
ingredientRepo IngredientRepository
|
ingredientRepo IngredientRepository
|
||||||
|
dishRepo DishRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new Handler.
|
// NewHandler creates a new Handler.
|
||||||
func NewHandler(recognizer Recognizer, repo IngredientRepository) *Handler {
|
func NewHandler(recognizer Recognizer, repo IngredientRepository, dishRepo DishRepository) *Handler {
|
||||||
return &Handler{recognizer: recognizer, ingredientRepo: repo}
|
return &Handler{recognizer: recognizer, ingredientRepo: repo, dishRepo: dishRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -159,6 +171,41 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve dish_id and recipe_id for each candidate in parallel.
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range result.Candidates {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
candidate := result.Candidates[i]
|
||||||
|
dishID, created, findError := h.dishRepo.FindOrCreate(r.Context(), candidate.DishName)
|
||||||
|
if findError != nil {
|
||||||
|
slog.Warn("find or create dish", "name", candidate.DishName, "err", findError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
result.Candidates[i].DishID = &dishID
|
||||||
|
mu.Unlock()
|
||||||
|
if created {
|
||||||
|
go h.enrichDishInBackground(dishID, candidate.DishName)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipeID, _, recipeError := h.dishRepo.FindOrCreateRecipe(
|
||||||
|
r.Context(), dishID,
|
||||||
|
candidate.Calories, candidate.ProteinG, candidate.FatG, candidate.CarbsG,
|
||||||
|
)
|
||||||
|
if recipeError != nil {
|
||||||
|
slog.Warn("find or create recipe", "dish_id", dishID, "err", recipeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
result.Candidates[i].RecipeID = &recipeID
|
||||||
|
mu.Unlock()
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +309,58 @@ func (h *Handler) saveClassification(ctx context.Context, c *ai.IngredientClassi
|
|||||||
return saved
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrichDishInBackground generates name translations for a newly created dish stub.
|
||||||
|
// Recipe creation is handled synchronously in RecognizeDish.
|
||||||
|
// Runs as a fire-and-forget goroutine so it never blocks the HTTP response.
|
||||||
|
func (h *Handler) enrichDishInBackground(dishID, dishName string) {
|
||||||
|
enrichContext := context.Background()
|
||||||
|
|
||||||
|
translations, translateError := h.recognizer.TranslateDishName(enrichContext, dishName)
|
||||||
|
if translateError != nil {
|
||||||
|
slog.Warn("translate dish name", "name", dishName, "err", translateError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for lang, translatedName := range translations {
|
||||||
|
if upsertError := h.dishRepo.UpsertTranslation(enrichContext, dishID, lang, translatedName); upsertError != nil {
|
||||||
|
slog.Warn("upsert dish translation", "dish_id", dishID, "lang", lang, "err", upsertError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiRecipeToCreateRequest converts an AI-generated recipe into a dish.CreateRequest.
|
||||||
|
func aiRecipeToCreateRequest(recipe *ai.Recipe) dish.CreateRequest {
|
||||||
|
ingredients := make([]dish.IngredientInput, len(recipe.Ingredients))
|
||||||
|
for i, ingredient := range recipe.Ingredients {
|
||||||
|
ingredients[i] = dish.IngredientInput{
|
||||||
|
Name: ingredient.Name, Amount: ingredient.Amount, Unit: ingredient.Unit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps := make([]dish.StepInput, len(recipe.Steps))
|
||||||
|
for i, step := range recipe.Steps {
|
||||||
|
steps[i] = dish.StepInput{
|
||||||
|
Number: step.Number, Description: step.Description, TimerSeconds: step.TimerSeconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dish.CreateRequest{
|
||||||
|
Name: recipe.Title,
|
||||||
|
Description: recipe.Description,
|
||||||
|
CuisineSlug: recipe.Cuisine,
|
||||||
|
ImageURL: recipe.ImageURL,
|
||||||
|
Tags: recipe.Tags,
|
||||||
|
Source: "ai",
|
||||||
|
Difficulty: recipe.Difficulty,
|
||||||
|
PrepTimeMin: recipe.PrepTimeMin,
|
||||||
|
CookTimeMin: recipe.CookTimeMin,
|
||||||
|
Servings: recipe.Servings,
|
||||||
|
Calories: recipe.Nutrition.Calories,
|
||||||
|
Protein: recipe.Nutrition.ProteinG,
|
||||||
|
Fat: recipe.Nutrition.FatG,
|
||||||
|
Carbs: recipe.Nutrition.CarbsG,
|
||||||
|
Ingredients: ingredients,
|
||||||
|
Steps: steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MergeAndDeduplicate combines results from multiple images.
|
// MergeAndDeduplicate combines results from multiple images.
|
||||||
// Items sharing the same name (case-insensitive) have their quantities summed.
|
// Items sharing the same name (case-insensitive) have their quantities summed.
|
||||||
func MergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem {
|
func MergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem {
|
||||||
|
|||||||
@@ -398,14 +398,9 @@ CREATE TABLE meal_diary (
|
|||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
date DATE NOT NULL,
|
date DATE NOT NULL,
|
||||||
meal_type TEXT NOT NULL,
|
meal_type TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
|
||||||
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
|
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',
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
dish_id UUID REFERENCES dishes(id) ON DELETE SET NULL,
|
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT,
|
||||||
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
|
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
|
||||||
portion_g DECIMAL(10,2),
|
portion_g DECIMAL(10,2),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
|||||||
@@ -39,8 +39,17 @@ func authorizedRequest(method, target string, body []byte) *http.Request {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newHandler(diaryRepo diary.DiaryRepository, dishRepo diary.DishRepository, recipeRepo diary.RecipeRepository) *diary.Handler {
|
||||||
|
return diary.NewHandler(diaryRepo, dishRepo, recipeRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultMocks() (*diarymocks.MockDiaryRepository, *diarymocks.MockDishRepository, *diarymocks.MockRecipeRepository) {
|
||||||
|
return &diarymocks.MockDiaryRepository{}, &diarymocks.MockDishRepository{}, &diarymocks.MockRecipeRepository{}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetByDate_MissingQueryParam(t *testing.T) {
|
func TestGetByDate_MissingQueryParam(t *testing.T) {
|
||||||
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
|
diaryRepo, dishRepo, recipeRepo := defaultMocks()
|
||||||
|
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
|
||||||
router := buildRouter(handler, "user-1")
|
router := buildRouter(handler, "user-1")
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@@ -59,7 +68,8 @@ func TestGetByDate_Success(t *testing.T) {
|
|||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
handler := diary.NewHandler(mockRepo)
|
_, dishRepo, recipeRepo := defaultMocks()
|
||||||
|
handler := newHandler(mockRepo, dishRepo, recipeRepo)
|
||||||
router := buildRouter(handler, "user-1")
|
router := buildRouter(handler, "user-1")
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@@ -79,7 +89,8 @@ func TestGetByDate_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreate_MissingDate(t *testing.T) {
|
func TestCreate_MissingDate(t *testing.T) {
|
||||||
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
|
diaryRepo, dishRepo, recipeRepo := defaultMocks()
|
||||||
|
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
|
||||||
router := buildRouter(handler, "user-1")
|
router := buildRouter(handler, "user-1")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"name": "Oatmeal", "meal_type": "breakfast"})
|
body, _ := json.Marshal(map[string]string{"name": "Oatmeal", "meal_type": "breakfast"})
|
||||||
@@ -91,8 +102,9 @@ func TestCreate_MissingDate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreate_MissingName(t *testing.T) {
|
func TestCreate_MissingNameAndDishID(t *testing.T) {
|
||||||
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
|
diaryRepo, dishRepo, recipeRepo := defaultMocks()
|
||||||
|
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
|
||||||
router := buildRouter(handler, "user-1")
|
router := buildRouter(handler, "user-1")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"date": "2026-03-15", "meal_type": "breakfast"})
|
body, _ := json.Marshal(map[string]string{"date": "2026-03-15", "meal_type": "breakfast"})
|
||||||
@@ -105,7 +117,8 @@ func TestCreate_MissingName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreate_MissingMealType(t *testing.T) {
|
func TestCreate_MissingMealType(t *testing.T) {
|
||||||
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
|
diaryRepo, dishRepo, recipeRepo := defaultMocks()
|
||||||
|
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
|
||||||
router := buildRouter(handler, "user-1")
|
router := buildRouter(handler, "user-1")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"date": "2026-03-15", "name": "Oatmeal"})
|
body, _ := json.Marshal(map[string]string{"date": "2026-03-15", "name": "Oatmeal"})
|
||||||
@@ -118,20 +131,31 @@ func TestCreate_MissingMealType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreate_Success(t *testing.T) {
|
func TestCreate_Success(t *testing.T) {
|
||||||
mockRepo := &diarymocks.MockDiaryRepository{
|
mockDiaryRepo := &diarymocks.MockDiaryRepository{
|
||||||
CreateFn: func(ctx context.Context, userID string, req diary.CreateRequest) (*diary.Entry, error) {
|
CreateFn: func(ctx context.Context, userID string, req diary.CreateRequest) (*diary.Entry, error) {
|
||||||
return &diary.Entry{
|
return &diary.Entry{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Date: req.Date,
|
Date: req.Date,
|
||||||
MealType: req.MealType,
|
MealType: req.MealType,
|
||||||
Name: req.Name,
|
Name: "Oatmeal",
|
||||||
Portions: 1,
|
Portions: 1,
|
||||||
Source: "manual",
|
Source: "manual",
|
||||||
|
DishID: "dish-1",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
handler := diary.NewHandler(mockRepo)
|
mockDishRepo := &diarymocks.MockDishRepository{
|
||||||
|
FindOrCreateFn: func(ctx context.Context, name string) (string, bool, error) {
|
||||||
|
return "dish-1", false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockRecipeRepo := &diarymocks.MockRecipeRepository{
|
||||||
|
FindOrCreateRecipeFn: func(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) {
|
||||||
|
return "recipe-1", false, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
handler := newHandler(mockDiaryRepo, mockDishRepo, mockRecipeRepo)
|
||||||
router := buildRouter(handler, "user-1")
|
router := buildRouter(handler, "user-1")
|
||||||
|
|
||||||
body, _ := json.Marshal(diary.CreateRequest{
|
body, _ := json.Marshal(diary.CreateRequest{
|
||||||
@@ -154,7 +178,8 @@ func TestDelete_NotFound(t *testing.T) {
|
|||||||
return diary.ErrNotFound
|
return diary.ErrNotFound
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
handler := diary.NewHandler(mockRepo)
|
_, dishRepo, recipeRepo := defaultMocks()
|
||||||
|
handler := newHandler(mockRepo, dishRepo, recipeRepo)
|
||||||
router := buildRouter(handler, "user-1")
|
router := buildRouter(handler, "user-1")
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@@ -171,7 +196,8 @@ func TestDelete_Success(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
handler := diary.NewHandler(mockRepo)
|
_, dishRepo, recipeRepo := defaultMocks()
|
||||||
|
handler := newHandler(mockRepo, dishRepo, recipeRepo)
|
||||||
router := buildRouter(handler, "user-1")
|
router := buildRouter(handler, "user-1")
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import '../../features/products/add_product_screen.dart';
|
|||||||
import '../../features/scan/scan_screen.dart';
|
import '../../features/scan/scan_screen.dart';
|
||||||
import '../../features/scan/recognition_confirm_screen.dart';
|
import '../../features/scan/recognition_confirm_screen.dart';
|
||||||
import '../../features/scan/recognition_service.dart';
|
import '../../features/scan/recognition_service.dart';
|
||||||
import '../../features/menu/diary_screen.dart';
|
|
||||||
import '../../features/menu/menu_screen.dart';
|
import '../../features/menu/menu_screen.dart';
|
||||||
import '../../features/menu/shopping_list_screen.dart';
|
import '../../features/menu/shopping_list_screen.dart';
|
||||||
import '../../features/recipes/recipe_detail_screen.dart';
|
import '../../features/recipes/recipe_detail_screen.dart';
|
||||||
@@ -129,14 +128,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return ShoppingListScreen(week: week);
|
return ShoppingListScreen(week: week);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Diary — full-screen, no bottom nav.
|
|
||||||
GoRoute(
|
|
||||||
path: '/menu/diary',
|
|
||||||
builder: (context, state) {
|
|
||||||
final date = state.extra as String? ?? '';
|
|
||||||
return DiaryScreen(date: date);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// Scan / recognition flow — all without bottom nav.
|
// Scan / recognition flow — all without bottom nav.
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/scan',
|
path: '/scan',
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
_ExpiringBanner(items: expiringSoon),
|
_ExpiringBanner(items: expiringSoon),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_QuickActionsRow(date: dateString),
|
_QuickActionsRow(),
|
||||||
if (recommendations.isNotEmpty) ...[
|
if (recommendations.isNotEmpty) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_SectionTitle('Рекомендуем приготовить'),
|
_SectionTitle('Рекомендуем приготовить'),
|
||||||
@@ -988,8 +988,7 @@ class _ExpiringBanner extends StatelessWidget {
|
|||||||
// ── Quick actions ─────────────────────────────────────────────
|
// ── Quick actions ─────────────────────────────────────────────
|
||||||
|
|
||||||
class _QuickActionsRow extends StatelessWidget {
|
class _QuickActionsRow extends StatelessWidget {
|
||||||
final String date;
|
const _QuickActionsRow();
|
||||||
const _QuickActionsRow({required this.date});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -1010,14 +1009,6 @@ class _QuickActionsRow extends StatelessWidget {
|
|||||||
onTap: () => context.push('/menu'),
|
onTap: () => context.push('/menu'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _ActionButton(
|
|
||||||
icon: Icons.book_outlined,
|
|
||||||
label: 'Дневник',
|
|
||||||
onTap: () => context.push('/menu/diary', extra: date),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,20 +150,6 @@ class _MenuContent extends StatelessWidget {
|
|||||||
label: const Text('Список покупок'),
|
label: const Text('Список покупок'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
final today = DateTime.now();
|
|
||||||
final dateStr =
|
|
||||||
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
|
|
||||||
context.push('/menu/diary', extra: dateStr);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.book_outlined),
|
|
||||||
label: const Text('Дневник питания'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
|
|||||||
'carbs_g': scaledCarbs,
|
'carbs_g': scaledCarbs,
|
||||||
'portion_g': _portionGrams,
|
'portion_g': _portionGrams,
|
||||||
'source': 'recognition',
|
'source': 'recognition',
|
||||||
|
if (_selected.dishId != null) 'dish_id': _selected.dishId,
|
||||||
});
|
});
|
||||||
if (mounted) widget.onAdded();
|
if (mounted) widget.onAdded();
|
||||||
} catch (addError) {
|
} catch (addError) {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class ReceiptResult {
|
|||||||
|
|
||||||
/// A single dish recognition candidate with estimated nutrition for the portion in the photo.
|
/// A single dish recognition candidate with estimated nutrition for the portion in the photo.
|
||||||
class DishCandidate {
|
class DishCandidate {
|
||||||
|
final String? dishId;
|
||||||
final String dishName;
|
final String dishName;
|
||||||
final int weightGrams;
|
final int weightGrams;
|
||||||
final double calories;
|
final double calories;
|
||||||
@@ -74,6 +75,7 @@ class DishCandidate {
|
|||||||
final double confidence;
|
final double confidence;
|
||||||
|
|
||||||
const DishCandidate({
|
const DishCandidate({
|
||||||
|
this.dishId,
|
||||||
required this.dishName,
|
required this.dishName,
|
||||||
required this.weightGrams,
|
required this.weightGrams,
|
||||||
required this.calories,
|
required this.calories,
|
||||||
@@ -85,6 +87,7 @@ class DishCandidate {
|
|||||||
|
|
||||||
factory DishCandidate.fromJson(Map<String, dynamic> json) {
|
factory DishCandidate.fromJson(Map<String, dynamic> json) {
|
||||||
return DishCandidate(
|
return DishCandidate(
|
||||||
|
dishId: json['dish_id'] as String?,
|
||||||
dishName: json['dish_name'] as String? ?? '',
|
dishName: json['dish_name'] as String? ?? '',
|
||||||
weightGrams: json['weight_grams'] as int? ?? 0,
|
weightGrams: json['weight_grams'] as int? ?? 0,
|
||||||
calories: (json['calories'] as num?)?.toDouble() ?? 0,
|
calories: (json['calories'] as num?)?.toDouble() ?? 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user