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 } // 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) { 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. var dishID string 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. for _, slug := range req.Tags { if _, err := tx.Exec(ctx, `INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`, dishID, slug, ); err != nil { return "", fmt.Errorf("insert dish tag %s: %w", slug, err) } } // 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 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 }