package recipe import ( "context" "encoding/json" "errors" "fmt" "github.com/food-ai/backend/internal/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Repository handles persistence for recipes and their translations. type Repository struct { pool *pgxpool.Pool } // NewRepository creates a new Repository. func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } // Upsert inserts or updates a recipe (English canonical content only). // Conflict is resolved on spoonacular_id. func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) { query := ` INSERT INTO recipes ( source, spoonacular_id, title, description, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, ingredients, steps, tags ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ON CONFLICT (spoonacular_id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, cuisine = EXCLUDED.cuisine, difficulty = EXCLUDED.difficulty, prep_time_min = EXCLUDED.prep_time_min, cook_time_min = EXCLUDED.cook_time_min, servings = EXCLUDED.servings, image_url = EXCLUDED.image_url, calories_per_serving = EXCLUDED.calories_per_serving, protein_per_serving = EXCLUDED.protein_per_serving, fat_per_serving = EXCLUDED.fat_per_serving, carbs_per_serving = EXCLUDED.carbs_per_serving, fiber_per_serving = EXCLUDED.fiber_per_serving, ingredients = EXCLUDED.ingredients, steps = EXCLUDED.steps, tags = EXCLUDED.tags, updated_at = now() RETURNING id, source, spoonacular_id, title, description, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, ingredients, steps, tags, avg_rating, review_count, created_by, created_at, updated_at` row := r.pool.QueryRow(ctx, query, recipe.Source, recipe.SpoonacularID, recipe.Title, recipe.Description, recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL, recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing, recipe.Ingredients, recipe.Steps, recipe.Tags, ) return scanRecipe(row) } // GetByID returns a recipe by UUID, with content resolved for the language // stored in ctx (falls back to English when no translation exists). // Returns nil, nil if not found. func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) { lang := locale.FromContext(ctx) query := ` SELECT r.id, r.source, r.spoonacular_id, COALESCE(rt.title, r.title) AS title, COALESCE(rt.description, r.description) AS description, r.cuisine, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, r.image_url, r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving, COALESCE(rt.ingredients, r.ingredients) AS ingredients, COALESCE(rt.steps, r.steps) AS steps, r.tags, r.avg_rating, r.review_count, r.created_by, 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.id = $1` row := r.pool.QueryRow(ctx, query, id, lang) rec, err := scanRecipe(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return rec, err } // Count returns the total number of recipes. func (r *Repository) Count(ctx context.Context) (int, error) { var n int if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM recipes`).Scan(&n); err != nil { return 0, fmt.Errorf("count recipes: %w", err) } return n, nil } // ListMissingTranslation returns Spoonacular recipes that have no translation // for the given language, ordered by review_count DESC. func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) { query := ` SELECT id, source, spoonacular_id, title, description, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, ingredients, steps, tags, avg_rating, review_count, created_by, created_at, updated_at FROM recipes WHERE source = 'spoonacular' AND NOT EXISTS ( SELECT 1 FROM recipe_translations rt WHERE rt.recipe_id = recipes.id AND rt.lang = $3 ) ORDER BY review_count DESC LIMIT $1 OFFSET $2` rows, err := r.pool.Query(ctx, query, limit, offset, lang) if err != nil { return nil, fmt.Errorf("list missing translation (%s): %w", lang, err) } defer rows.Close() return collectRecipes(rows) } // UpsertTranslation inserts or replaces a recipe translation for a specific language. func (r *Repository) UpsertTranslation( ctx context.Context, id, lang string, title, description *string, ingredients, steps json.RawMessage, ) error { query := ` INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (recipe_id, lang) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, ingredients = EXCLUDED.ingredients, steps = EXCLUDED.steps` if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil { return fmt.Errorf("upsert recipe translation %s/%s: %w", id, lang, err) } return nil } // --- helpers --- func scanRecipe(row pgx.Row) (*Recipe, error) { var rec Recipe var ingredients, steps, tags []byte err := row.Scan( &rec.ID, &rec.Source, &rec.SpoonacularID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, &ingredients, &steps, &tags, &rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt, ) if err != nil { return nil, err } rec.Ingredients = json.RawMessage(ingredients) rec.Steps = json.RawMessage(steps) rec.Tags = json.RawMessage(tags) return &rec, nil } func collectRecipes(rows pgx.Rows) ([]*Recipe, error) { var result []*Recipe for rows.Next() { var rec Recipe var ingredients, steps, tags []byte if err := rows.Scan( &rec.ID, &rec.Source, &rec.SpoonacularID, &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, &ingredients, &steps, &tags, &rec.AvgRating, &rec.ReviewCount, &rec.CreatedBy, &rec.CreatedAt, &rec.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan recipe: %w", err) } rec.Ingredients = json.RawMessage(ingredients) rec.Steps = json.RawMessage(steps) rec.Tags = json.RawMessage(tags) result = append(result, &rec) } return result, rows.Err() }