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:
dbastrikin
2026-03-18 13:28:37 +02:00
parent a32d2960c4
commit ad00998344
16 changed files with 503 additions and 109 deletions

View File

@@ -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) {