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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user