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)
|
||||
ingredientHandler := ingredient.NewHandler(ingredientRepository)
|
||||
productHandler := product.NewHandler(productRepository)
|
||||
recognitionHandler := recognition.NewHandler(client, ingredientRepository)
|
||||
recognitionHandler := recognition.NewHandler(client, ingredientRepository, dishRepository)
|
||||
menuRepository := menu.NewRepository(pool)
|
||||
menuHandler := menu.NewHandler(menuRepository, client, pexelsClient, repository, productRepository, dishRepository)
|
||||
diaryRepository := diary.NewRepository(pool)
|
||||
diaryHandler := diary.NewHandler(diaryRepository)
|
||||
diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository)
|
||||
homeHandler := home.NewHandler(pool)
|
||||
dishHandler := dish.NewHandler(dishRepository)
|
||||
recipeRepository := recipe.NewRepository(pool)
|
||||
|
||||
@@ -96,6 +96,8 @@ type ReceiptResult struct {
|
||||
|
||||
// DishCandidate is a single dish recognition candidate with estimated nutrition.
|
||||
type DishCandidate struct {
|
||||
DishID *string `json:"dish_id,omitempty"`
|
||||
RecipeID *string `json:"recipe_id,omitempty"`
|
||||
DishName string `json:"dish_name"`
|
||||
WeightGrams int `json:"weight_grams"`
|
||||
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"`
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
MealType string `json:"meal_type"`
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"` // from dishes JOIN
|
||||
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"`
|
||||
FatG *float64 `json:"fat_g,omitempty"`
|
||||
CarbsG *float64 `json:"carbs_g,omitempty"`
|
||||
Source string `json:"source"`
|
||||
DishID *string `json:"dish_id,omitempty"`
|
||||
DishID string `json:"dish_id"`
|
||||
RecipeID *string `json:"recipe_id,omitempty"`
|
||||
PortionG *float64 `json:"portion_g,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -24,12 +24,8 @@ type Entry struct {
|
||||
type CreateRequest struct {
|
||||
Date string `json:"date"`
|
||||
MealType string `json:"meal_type"`
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"` // input-only; used if DishID is nil
|
||||
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"`
|
||||
DishID *string `json:"dish_id"`
|
||||
RecipeID *string `json:"recipe_id"`
|
||||
|
||||
@@ -17,14 +17,26 @@ type DiaryRepository interface {
|
||||
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.
|
||||
type Handler struct {
|
||||
repo DiaryRepository
|
||||
repo DiaryRepository
|
||||
dishRepo DishRepository
|
||||
recipeRepo RecipeRepository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(repo DiaryRepository) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
func NewHandler(repo DiaryRepository, dishRepo DishRepository, recipeRepo RecipeRepository) *Handler {
|
||||
return &Handler{repo: repo, dishRepo: dishRepo, recipeRepo: recipeRepo}
|
||||
}
|
||||
|
||||
// GetByDate handles GET /diary?date=YYYY-MM-DD
|
||||
@@ -41,9 +53,9 @@ func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.repo.ListByDate(r.Context(), userID, date)
|
||||
if err != nil {
|
||||
slog.Error("list diary by date", "err", err)
|
||||
entries, listError := h.repo.ListByDate(r.Context(), userID, date)
|
||||
if listError != nil {
|
||||
slog.Error("list diary by date", "err", listError)
|
||||
writeError(w, http.StatusInternalServerError, "failed to load diary")
|
||||
return
|
||||
}
|
||||
@@ -62,18 +74,42 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
if req.Date == "" || req.Name == "" || req.MealType == "" {
|
||||
writeError(w, http.StatusBadRequest, "date, meal_type and name are required")
|
||||
if req.Date == "" || req.MealType == "" {
|
||||
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
|
||||
}
|
||||
|
||||
entry, err := h.repo.Create(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
slog.Error("create diary entry", "err", err)
|
||||
if req.DishID == nil {
|
||||
dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name)
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -89,12 +125,12 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
|
||||
if err == ErrNotFound {
|
||||
if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil {
|
||||
if deleteError == ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "diary entry not found")
|
||||
return
|
||||
}
|
||||
slog.Error("delete diary entry", "err", err)
|
||||
slog.Error("delete diary entry", "err", deleteError)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete diary entry")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,30 @@ import (
|
||||
"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.
|
||||
type MockDiaryRepository struct {
|
||||
ListByDateFn func(ctx context.Context, userID, date string) ([]*diary.Entry, error)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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).
|
||||
// Dish name and macros are computed via JOIN with dishes and recipes.
|
||||
func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) {
|
||||
lang := locale.FromContext(ctx)
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT id, date::text, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g,
|
||||
source, dish_id, recipe_id, portion_g, created_at
|
||||
FROM meal_diary
|
||||
WHERE user_id = $1 AND date = $2::date
|
||||
ORDER BY created_at ASC`, userID, date)
|
||||
SELECT
|
||||
md.id, md.date::text, md.meal_type, md.portions,
|
||||
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at,
|
||||
COALESCE(dt.name, d.name) AS dish_name,
|
||||
r.calories_per_serving * md.portions,
|
||||
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 {
|
||||
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
|
||||
for rows.Next() {
|
||||
e, err := scanEntry(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan diary entry: %w", err)
|
||||
entry, scanError := scanEntry(rows)
|
||||
if scanError != nil {
|
||||
return nil, fmt.Errorf("scan diary entry: %w", scanError)
|
||||
}
|
||||
result = append(result, e)
|
||||
result = append(result, entry)
|
||||
}
|
||||
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) {
|
||||
lang := locale.FromContext(ctx)
|
||||
portions := req.Portions
|
||||
if portions <= 0 {
|
||||
portions = 1
|
||||
@@ -58,25 +70,40 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
|
||||
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, `
|
||||
INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g)
|
||||
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, date::text, meal_type, name, portions,
|
||||
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g, created_at`,
|
||||
userID, req.Date, req.MealType, req.Name, portions,
|
||||
req.Calories, req.ProteinG, req.FatG, req.CarbsG,
|
||||
source, req.DishID, req.RecipeID, req.PortionG,
|
||||
)
|
||||
SELECT
|
||||
md.id, md.date::text, md.meal_type, md.portions,
|
||||
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at,
|
||||
COALESCE(dt.name, d.name) AS dish_name,
|
||||
r.calories_per_serving * md.portions,
|
||||
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 = $2
|
||||
LEFT JOIN recipes r ON r.id = md.recipe_id
|
||||
WHERE md.id = $1`, entryID, lang)
|
||||
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,
|
||||
tag, deleteError := 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 deleteError != nil {
|
||||
return fmt.Errorf("delete diary entry: %w", deleteError)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
@@ -91,14 +118,15 @@ type scannable interface {
|
||||
}
|
||||
|
||||
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.DishID, &e.RecipeID, &e.PortionG, &e.CreatedAt,
|
||||
var entry Entry
|
||||
scanError := s.Scan(
|
||||
&entry.ID, &entry.Date, &entry.MealType, &entry.Portions,
|
||||
&entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.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 &e, err
|
||||
return &entry, scanError
|
||||
}
|
||||
|
||||
@@ -98,6 +98,125 @@ func (r *Repository) List(ctx context.Context) ([]*Dish, error) {
|
||||
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.
|
||||
// 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) {
|
||||
|
||||
@@ -9,11 +9,20 @@ import (
|
||||
"sync"
|
||||
|
||||
"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/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.
|
||||
type IngredientRepository interface {
|
||||
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)
|
||||
RecognizeDish(ctx context.Context, imageBase64, mimeType, lang string) (*ai.DishResult, 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.
|
||||
type Handler struct {
|
||||
recognizer Recognizer
|
||||
ingredientRepo IngredientRepository
|
||||
dishRepo DishRepository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(recognizer Recognizer, repo IngredientRepository) *Handler {
|
||||
return &Handler{recognizer: recognizer, ingredientRepo: repo}
|
||||
func NewHandler(recognizer Recognizer, repo IngredientRepository, dishRepo DishRepository) *Handler {
|
||||
return &Handler{recognizer: recognizer, ingredientRepo: repo, dishRepo: dishRepo}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -159,6 +171,41 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -262,6 +309,58 @@ func (h *Handler) saveClassification(ctx context.Context, c *ai.IngredientClassi
|
||||
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.
|
||||
// Items sharing the same name (case-insensitive) have their quantities summed.
|
||||
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,
|
||||
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',
|
||||
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,
|
||||
portion_g DECIMAL(10,2),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
|
||||
@@ -39,8 +39,17 @@ func authorizedRequest(method, target string, body []byte) *http.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) {
|
||||
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
|
||||
diaryRepo, dishRepo, recipeRepo := defaultMocks()
|
||||
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
|
||||
router := buildRouter(handler, "user-1")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -59,7 +68,8 @@ func TestGetByDate_Success(t *testing.T) {
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
handler := diary.NewHandler(mockRepo)
|
||||
_, dishRepo, recipeRepo := defaultMocks()
|
||||
handler := newHandler(mockRepo, dishRepo, recipeRepo)
|
||||
router := buildRouter(handler, "user-1")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -79,7 +89,8 @@ func TestGetByDate_Success(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")
|
||||
|
||||
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) {
|
||||
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
|
||||
func TestCreate_MissingNameAndDishID(t *testing.T) {
|
||||
diaryRepo, dishRepo, recipeRepo := defaultMocks()
|
||||
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
|
||||
router := buildRouter(handler, "user-1")
|
||||
|
||||
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) {
|
||||
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
|
||||
diaryRepo, dishRepo, recipeRepo := defaultMocks()
|
||||
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
|
||||
router := buildRouter(handler, "user-1")
|
||||
|
||||
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) {
|
||||
mockRepo := &diarymocks.MockDiaryRepository{
|
||||
mockDiaryRepo := &diarymocks.MockDiaryRepository{
|
||||
CreateFn: func(ctx context.Context, userID string, req diary.CreateRequest) (*diary.Entry, error) {
|
||||
return &diary.Entry{
|
||||
ID: "entry-1",
|
||||
Date: req.Date,
|
||||
MealType: req.MealType,
|
||||
Name: req.Name,
|
||||
Name: "Oatmeal",
|
||||
Portions: 1,
|
||||
Source: "manual",
|
||||
DishID: "dish-1",
|
||||
CreatedAt: time.Now(),
|
||||
}, 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")
|
||||
|
||||
body, _ := json.Marshal(diary.CreateRequest{
|
||||
@@ -154,7 +178,8 @@ func TestDelete_NotFound(t *testing.T) {
|
||||
return diary.ErrNotFound
|
||||
},
|
||||
}
|
||||
handler := diary.NewHandler(mockRepo)
|
||||
_, dishRepo, recipeRepo := defaultMocks()
|
||||
handler := newHandler(mockRepo, dishRepo, recipeRepo)
|
||||
router := buildRouter(handler, "user-1")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -171,7 +196,8 @@ func TestDelete_Success(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
handler := diary.NewHandler(mockRepo)
|
||||
_, dishRepo, recipeRepo := defaultMocks()
|
||||
handler := newHandler(mockRepo, dishRepo, recipeRepo)
|
||||
router := buildRouter(handler, "user-1")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
Reference in New Issue
Block a user