package dish import ( "context" "errors" "fmt" "github.com/food-ai/backend/internal/infra/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Repository handles persistence for dishes and their recipes. type Repository struct { pool *pgxpool.Pool } // NewRepository creates a new Repository. func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } // GetByID returns a dish with all its tag slugs and recipe variants. // Text is resolved for the language in ctx (English fallback). // Returns nil, nil if not found. func (r *Repository) GetByID(ctx context.Context, id string) (*Dish, error) { lang := locale.FromContext(ctx) const q = ` SELECT d.id, d.cuisine_slug, d.category_slug, COALESCE(dt.name, d.name) AS name, COALESCE(dt.description, d.description) AS description, d.image_url, d.avg_rating, d.review_count, d.created_at, d.updated_at FROM dishes d LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2 WHERE d.id = $1` row := r.pool.QueryRow(ctx, q, id, lang) dish, err := scanDish(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("get dish %s: %w", id, err) } if err := r.loadTags(ctx, dish); err != nil { return nil, err } if err := r.loadRecipes(ctx, dish, lang); err != nil { return nil, err } return dish, nil } // List returns all dishes with tag slugs (no recipe variants). // Text is resolved for the language in ctx. func (r *Repository) List(ctx context.Context) ([]*Dish, error) { lang := locale.FromContext(ctx) const q = ` SELECT d.id, d.cuisine_slug, d.category_slug, COALESCE(dt.name, d.name) AS name, COALESCE(dt.description, d.description) AS description, d.image_url, d.avg_rating, d.review_count, d.created_at, d.updated_at FROM dishes d LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $1 ORDER BY d.updated_at DESC` rows, err := r.pool.Query(ctx, q, lang) if err != nil { return nil, fmt.Errorf("list dishes: %w", err) } defer rows.Close() var dishes []*Dish for rows.Next() { d, err := scanDish(rows) if err != nil { return nil, fmt.Errorf("scan dish: %w", err) } dishes = append(dishes, d) } if err := rows.Err(); err != nil { return nil, err } // Load tags for each dish. for _, d := range dishes { if err := r.loadTags(ctx, d); err != nil { return nil, err } } return dishes, nil } // Search finds dishes matching the query string using FTS + trigram similarity. // Text is resolved for the language in ctx (English fallback). func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*DishSearchResult, error) { if limit <= 0 || limit > 50 { limit = 10 } lang := locale.FromContext(ctx) const searchQuery = ` SELECT d.id, COALESCE(dt.name, d.name) AS name, d.image_url, d.avg_rating, MIN(r.calories_per_serving) AS calories_per_serving, GREATEST( ts_rank(to_tsvector('english', d.name), plainto_tsquery('english', $1)), ts_rank(to_tsvector('simple', COALESCE(dt.name, '')), plainto_tsquery('simple', $1)), similarity(COALESCE(dt.name, d.name), $1) ) AS rank FROM dishes d LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 LEFT JOIN recipes r ON r.dish_id = d.id WHERE ( to_tsvector('english', d.name) @@ plainto_tsquery('english', $1) OR to_tsvector('simple', COALESCE(dt.name, '')) @@ plainto_tsquery('simple', $1) OR d.name ILIKE '%' || $1 || '%' OR dt.name ILIKE '%' || $1 || '%' OR similarity(COALESCE(dt.name, d.name), $1) > 0.3 ) GROUP BY d.id, dt.name, d.name, d.image_url, d.avg_rating ORDER BY rank DESC LIMIT $2` rows, queryError := r.pool.Query(ctx, searchQuery, query, limit, lang) if queryError != nil { return nil, fmt.Errorf("search dishes: %w", queryError) } defer rows.Close() var results []*DishSearchResult for rows.Next() { var result DishSearchResult var rank float64 if scanError := rows.Scan(&result.ID, &result.Name, &result.ImageURL, &result.AvgRating, &result.CaloriesPerServing, &rank); scanError != nil { return nil, fmt.Errorf("scan dish search result: %w", scanError) } results = append(results, &result) } if err := rows.Err(); err != nil { return nil, err } return results, 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 } // GetTranslation returns the translated dish name for a given language. // Returns ("", false, nil) if no translation exists yet. func (r *Repository) GetTranslation(ctx context.Context, dishID, lang string) (string, bool, error) { var name string queryError := r.pool.QueryRow(ctx, `SELECT name FROM dish_translations WHERE dish_id = $1 AND lang = $2`, dishID, lang, ).Scan(&name) if errors.Is(queryError, pgx.ErrNoRows) { return "", false, nil } if queryError != nil { return "", false, fmt.Errorf("get dish translation %s/%s: %w", dishID, lang, queryError) } return name, 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 dish ID and the recipe ID; the recipe ID is used in menu_items // or user_saved_recipes, and the dish ID is used for upsert of translations. func (r *Repository) Create(ctx context.Context, req CreateRequest) (dishID, recipeID string, err error) { tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return "", "", fmt.Errorf("begin tx: %w", err) } defer tx.Rollback(ctx) //nolint:errcheck // Resolve cuisine slug (empty string → NULL). cuisineSlug := nullableStr(req.CuisineSlug) // Insert dish. err = tx.QueryRow(ctx, ` INSERT INTO dishes (cuisine_slug, name, description, image_url) VALUES ($1, $2, NULLIF($3,''), NULLIF($4,'')) RETURNING id`, cuisineSlug, req.Name, req.Description, req.ImageURL, ).Scan(&dishID) if err != nil { return "", "", fmt.Errorf("insert dish: %w", err) } // Insert tags — upsert into tags first so the FK constraint is satisfied // even when the AI generates a tag slug that does not exist yet. for _, slug := range req.Tags { if _, upsertErr := tx.Exec(ctx, `INSERT INTO tags (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO NOTHING`, slug, slug, ); upsertErr != nil { return "", "", fmt.Errorf("upsert tag %s: %w", slug, upsertErr) } if _, insertErr := tx.Exec(ctx, `INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`, dishID, slug, ); insertErr != nil { return "", "", fmt.Errorf("insert dish tag %s: %w", slug, insertErr) } } // Insert recipe. source := req.Source if source == "" { source = "ai" } difficulty := nullableStr(req.Difficulty) prepTime := nullableInt(req.PrepTimeMin) cookTime := nullableInt(req.CookTimeMin) servings := nullableInt(req.Servings) calories := nullableFloat(req.Calories) protein := nullableFloat(req.Protein) fat := nullableFloat(req.Fat) carbs := nullableFloat(req.Carbs) err = tx.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, difficulty, prepTime, cookTime, servings, calories, protein, fat, carbs, ).Scan(&recipeID) if err != nil { return "", "", fmt.Errorf("insert recipe: %w", err) } // Insert recipe_ingredients. for i, ing := range req.Ingredients { if _, err := tx.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, ing.Name, ing.Amount, ing.Unit, ing.IsOptional, i, ); err != nil { return "", "", fmt.Errorf("insert ingredient %d: %w", i, err) } } // Insert recipe_steps. for _, s := range req.Steps { num := s.Number if num <= 0 { num = 1 } if _, err := tx.Exec(ctx, ` INSERT INTO recipe_steps (recipe_id, step_number, timer_seconds, description) VALUES ($1, $2, $3, $4)`, recipeID, num, s.TimerSeconds, s.Description, ); err != nil { return "", "", fmt.Errorf("insert step %d: %w", num, err) } } if err := tx.Commit(ctx); err != nil { return "", "", fmt.Errorf("commit: %w", err) } return dishID, recipeID, nil } // loadTags fills d.Tags with the slugs from dish_tags. func (r *Repository) loadTags(ctx context.Context, d *Dish) error { rows, err := r.pool.Query(ctx, `SELECT tag_slug FROM dish_tags WHERE dish_id = $1 ORDER BY tag_slug`, d.ID) if err != nil { return fmt.Errorf("load tags for dish %s: %w", d.ID, err) } defer rows.Close() for rows.Next() { var slug string if err := rows.Scan(&slug); err != nil { return err } d.Tags = append(d.Tags, slug) } return rows.Err() } // loadRecipes fills d.Recipes with all recipe variants for the dish, // including their relational ingredients and steps. func (r *Repository) loadRecipes(ctx context.Context, d *Dish, lang string) error { rows, err := r.pool.Query(ctx, ` SELECT r.id, r.dish_id, r.source, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving, rt.notes, r.created_at, r.updated_at FROM recipes r LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2 WHERE r.dish_id = $1 ORDER BY r.created_at DESC`, d.ID, lang) if err != nil { return fmt.Errorf("load recipes for dish %s: %w", d.ID, err) } defer rows.Close() for rows.Next() { rec, err := scanRecipe(rows) if err != nil { return fmt.Errorf("scan recipe: %w", err) } d.Recipes = append(d.Recipes, *rec) } if err := rows.Err(); err != nil { return err } // Load ingredients and steps for each recipe. for i := range d.Recipes { if err := r.loadIngredients(ctx, &d.Recipes[i], lang); err != nil { return err } if err := r.loadSteps(ctx, &d.Recipes[i], lang); err != nil { return err } } return nil } // loadIngredients fills rec.Ingredients from recipe_ingredients. func (r *Repository) loadIngredients(ctx context.Context, rec *Recipe, lang string) error { rows, err := r.pool.Query(ctx, ` SELECT ri.id, ri.ingredient_id, COALESCE(rit.name, ri.name) AS name, ri.amount, ri.unit_code, ri.is_optional, ri.sort_order FROM recipe_ingredients ri LEFT JOIN recipe_ingredient_translations rit ON rit.ri_id = ri.id AND rit.lang = $2 WHERE ri.recipe_id = $1 ORDER BY ri.sort_order`, rec.ID, lang) if err != nil { return fmt.Errorf("load ingredients for recipe %s: %w", rec.ID, err) } defer rows.Close() for rows.Next() { var ing RecipeIngredient if err := rows.Scan( &ing.ID, &ing.IngredientID, &ing.Name, &ing.Amount, &ing.UnitCode, &ing.IsOptional, &ing.SortOrder, ); err != nil { return fmt.Errorf("scan ingredient: %w", err) } rec.Ingredients = append(rec.Ingredients, ing) } return rows.Err() } // loadSteps fills rec.Steps from recipe_steps. func (r *Repository) loadSteps(ctx context.Context, rec *Recipe, lang string) error { rows, err := r.pool.Query(ctx, ` SELECT rs.id, rs.step_number, COALESCE(rst.description, rs.description) AS description, rs.timer_seconds, rs.image_url FROM recipe_steps rs LEFT JOIN recipe_step_translations rst ON rst.step_id = rs.id AND rst.lang = $2 WHERE rs.recipe_id = $1 ORDER BY rs.step_number`, rec.ID, lang) if err != nil { return fmt.Errorf("load steps for recipe %s: %w", rec.ID, err) } defer rows.Close() for rows.Next() { var s RecipeStep if err := rows.Scan( &s.ID, &s.StepNumber, &s.Description, &s.TimerSeconds, &s.ImageURL, ); err != nil { return fmt.Errorf("scan step: %w", err) } rec.Steps = append(rec.Steps, s) } return rows.Err() } // --- scan helpers --- type scannable interface { Scan(dest ...any) error } func scanDish(s scannable) (*Dish, error) { var d Dish err := s.Scan( &d.ID, &d.CuisineSlug, &d.CategorySlug, &d.Name, &d.Description, &d.ImageURL, &d.AvgRating, &d.ReviewCount, &d.CreatedAt, &d.UpdatedAt, ) if err != nil { return nil, err } d.Tags = []string{} return &d, nil } func scanRecipe(s scannable) (*Recipe, error) { var rec Recipe err := s.Scan( &rec.ID, &rec.DishID, &rec.Source, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, &rec.Notes, &rec.CreatedAt, &rec.UpdatedAt, ) if err != nil { return nil, err } rec.Ingredients = []RecipeIngredient{} rec.Steps = []RecipeStep{} return &rec, nil } // --- null helpers --- func nullableStr(s string) *string { if s == "" { return nil } return &s } func nullableInt(n int) *int { if n <= 0 { return nil } return &n } func nullableFloat(f float64) *float64 { if f <= 0 { return nil } return &f }