package recipe import ( "context" "encoding/json" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Repository handles persistence for recipes. 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. // 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, title_ru, description_ru, 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, $19, $20) 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, title_ru, description_ru, 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.TitleRu, recipe.DescriptionRu, 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) } // 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 } // ListUntranslated returns recipes without a Russian title, ordered by review_count DESC. func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*Recipe, error) { query := ` SELECT id, source, spoonacular_id, title, description, title_ru, description_ru, 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 title_ru IS NULL AND source = 'spoonacular' ORDER BY review_count DESC LIMIT $1 OFFSET $2` rows, err := r.pool.Query(ctx, query, limit, offset) if err != nil { return nil, fmt.Errorf("list untranslated recipes: %w", err) } defer rows.Close() return collectRecipes(rows) } // UpdateTranslation saves the Russian title, description, and step translations. func (r *Repository) UpdateTranslation(ctx context.Context, id string, titleRu, descriptionRu *string, steps json.RawMessage) error { query := ` UPDATE recipes SET title_ru = $2, description_ru = $3, steps = $4, updated_at = now() WHERE id = $1` if _, err := r.pool.Exec(ctx, query, id, titleRu, descriptionRu, steps); err != nil { return fmt.Errorf("update recipe translation %s: %w", id, 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.TitleRu, &rec.DescriptionRu, &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.TitleRu, &rec.DescriptionRu, &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() } // GetByID returns a recipe by UUID. // Returns nil, nil if not found. func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) { query := ` SELECT id, source, spoonacular_id, title, description, title_ru, description_ru, 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 id = $1` row := r.pool.QueryRow(ctx, query, id) rec, err := scanRecipe(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return rec, err }