package savedrecipe import ( "context" "encoding/json" "errors" "fmt" "github.com/food-ai/backend/internal/dish" "github.com/food-ai/backend/internal/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Repository handles persistence for user_saved_recipes. type Repository struct { pool *pgxpool.Pool dishRepo *dish.Repository } // NewRepository creates a new Repository. func NewRepository(pool *pgxpool.Pool, dishRepo *dish.Repository) *Repository { return &Repository{pool: pool, dishRepo: dishRepo} } // Save bookmarks a recipe for the user. // If req.RecipeID is set, that existing catalog recipe is bookmarked. // Otherwise a new dish + recipe is created from the supplied fields. func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*UserSavedRecipe, error) { recipeID := req.RecipeID if recipeID == "" { // Build a dish.CreateRequest from the save body. cr := dish.CreateRequest{ Name: req.Title, Description: req.Description, CuisineSlug: mapCuisineSlug(req.Cuisine), ImageURL: req.ImageURL, Source: req.Source, Difficulty: req.Difficulty, PrepTimeMin: req.PrepTimeMin, CookTimeMin: req.CookTimeMin, Servings: req.Servings, } // Unmarshal ingredients. if req.Ingredients != nil { switch v := req.Ingredients.(type) { case []interface{}: for i, item := range v { m, ok := item.(map[string]interface{}) if !ok { continue } ing := dish.IngredientInput{ Name: strVal(m["name"]), Amount: floatVal(m["amount"]), Unit: strVal(m["unit"]), } cr.Ingredients = append(cr.Ingredients, ing) _ = i } case json.RawMessage: var items []dish.IngredientInput if err := json.Unmarshal(v, &items); err == nil { cr.Ingredients = items } } } // Unmarshal steps. if req.Steps != nil { switch v := req.Steps.(type) { case []interface{}: for i, item := range v { m, ok := item.(map[string]interface{}) if !ok { continue } num := int(floatVal(m["number"])) if num <= 0 { num = i + 1 } step := dish.StepInput{ Number: num, Description: strVal(m["description"]), } cr.Steps = append(cr.Steps, step) } case json.RawMessage: var items []dish.StepInput if err := json.Unmarshal(v, &items); err == nil { cr.Steps = items } } } // Unmarshal tags. if req.Tags != nil { switch v := req.Tags.(type) { case []interface{}: for _, t := range v { if s, ok := t.(string); ok { cr.Tags = append(cr.Tags, s) } } case json.RawMessage: var items []string if err := json.Unmarshal(v, &items); err == nil { cr.Tags = items } } } // Unmarshal nutrition. if req.Nutrition != nil { switch v := req.Nutrition.(type) { case map[string]interface{}: cr.Calories = floatVal(v["calories"]) cr.Protein = floatVal(v["protein_g"]) cr.Fat = floatVal(v["fat_g"]) cr.Carbs = floatVal(v["carbs_g"]) case json.RawMessage: var nut struct { Calories float64 `json:"calories"` Protein float64 `json:"protein_g"` Fat float64 `json:"fat_g"` Carbs float64 `json:"carbs_g"` } if err := json.Unmarshal(v, &nut); err == nil { cr.Calories = nut.Calories cr.Protein = nut.Protein cr.Fat = nut.Fat cr.Carbs = nut.Carbs } } } var err error recipeID, err = r.dishRepo.Create(ctx, cr) if err != nil { return nil, fmt.Errorf("create dish+recipe: %w", err) } } // Insert bookmark. const q = ` INSERT INTO user_saved_recipes (user_id, recipe_id) VALUES ($1, $2) ON CONFLICT (user_id, recipe_id) DO UPDATE SET saved_at = now() RETURNING id, user_id, recipe_id, saved_at` var usr UserSavedRecipe err := r.pool.QueryRow(ctx, q, userID, recipeID).Scan( &usr.ID, &usr.UserID, &usr.RecipeID, &usr.SavedAt, ) if err != nil { return nil, fmt.Errorf("insert bookmark: %w", err) } return r.enrichOne(ctx, &usr) } // List returns all bookmarked recipes for userID ordered by saved_at DESC. func (r *Repository) List(ctx context.Context, userID string) ([]*UserSavedRecipe, error) { lang := locale.FromContext(ctx) const q = ` SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at, COALESCE(dt.name, d.name) AS dish_name, COALESCE(dt.description, d.description) AS description, d.image_url, d.cuisine_slug, 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 FROM user_saved_recipes usr JOIN recipes r ON r.id = usr.recipe_id JOIN dishes d ON d.id = r.dish_id LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2 WHERE usr.user_id = $1 ORDER BY usr.saved_at DESC` rows, err := r.pool.Query(ctx, q, userID, lang) if err != nil { return nil, fmt.Errorf("list saved recipes: %w", err) } defer rows.Close() var result []*UserSavedRecipe for rows.Next() { rec, err := scanUSR(rows) if err != nil { return nil, fmt.Errorf("scan saved recipe: %w", err) } if err := r.loadTags(ctx, rec); err != nil { return nil, err } if err := r.loadIngredients(ctx, rec, lang); err != nil { return nil, err } if err := r.loadSteps(ctx, rec, lang); err != nil { return nil, err } result = append(result, rec) } return result, rows.Err() } // GetByID returns a bookmarked recipe by its bookmark ID for userID. // Returns nil, nil if not found. func (r *Repository) GetByID(ctx context.Context, userID, id string) (*UserSavedRecipe, error) { lang := locale.FromContext(ctx) const q = ` SELECT usr.id, usr.user_id, usr.recipe_id, usr.saved_at, COALESCE(dt.name, d.name) AS dish_name, COALESCE(dt.description, d.description) AS description, d.image_url, d.cuisine_slug, 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 FROM user_saved_recipes usr JOIN recipes r ON r.id = usr.recipe_id JOIN dishes d ON d.id = r.dish_id LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 WHERE usr.id = $1 AND usr.user_id = $2` rec, err := scanUSR(r.pool.QueryRow(ctx, q, id, userID, lang)) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } if err := r.loadTags(ctx, rec); err != nil { return nil, err } if err := r.loadIngredients(ctx, rec, lang); err != nil { return nil, err } if err := r.loadSteps(ctx, rec, lang); err != nil { return nil, err } return rec, nil } // Delete removes a bookmark. func (r *Repository) Delete(ctx context.Context, userID, id string) error { tag, err := r.pool.Exec(ctx, `DELETE FROM user_saved_recipes WHERE id = $1 AND user_id = $2`, id, userID) if err != nil { return fmt.Errorf("delete saved recipe: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // --- helpers --- func (r *Repository) enrichOne(ctx context.Context, usr *UserSavedRecipe) (*UserSavedRecipe, error) { lang := locale.FromContext(ctx) const q = ` SELECT COALESCE(dt.name, d.name) AS dish_name, COALESCE(dt.description, d.description) AS description, d.image_url, d.cuisine_slug, rec.difficulty, rec.prep_time_min, rec.cook_time_min, rec.servings, rec.calories_per_serving, rec.protein_per_serving, rec.fat_per_serving, rec.carbs_per_serving FROM recipes rec JOIN dishes d ON d.id = rec.dish_id LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2 WHERE rec.id = $1` row := r.pool.QueryRow(ctx, q, usr.RecipeID, lang) if err := row.Scan( &usr.DishName, &usr.Description, &usr.ImageURL, &usr.CuisineSlug, &usr.Difficulty, &usr.PrepTimeMin, &usr.CookTimeMin, &usr.Servings, &usr.CaloriesPerServing, &usr.ProteinPerServing, &usr.FatPerServing, &usr.CarbsPerServing, ); err != nil { return nil, fmt.Errorf("enrich saved recipe: %w", err) } if err := r.loadTags(ctx, usr); err != nil { return nil, err } if err := r.loadIngredients(ctx, usr, lang); err != nil { return nil, err } return usr, r.loadSteps(ctx, usr, lang) } func (r *Repository) loadTags(ctx context.Context, usr *UserSavedRecipe) error { rows, err := r.pool.Query(ctx, ` SELECT dt.tag_slug FROM dish_tags dt JOIN recipes rec ON rec.dish_id = dt.dish_id JOIN user_saved_recipes usr ON usr.recipe_id = rec.id WHERE usr.id = $1 ORDER BY dt.tag_slug`, usr.ID) if err != nil { return fmt.Errorf("load tags for saved recipe %s: %w", usr.ID, err) } defer rows.Close() usr.Tags = []string{} for rows.Next() { var slug string if err := rows.Scan(&slug); err != nil { return err } usr.Tags = append(usr.Tags, slug) } return rows.Err() } func (r *Repository) loadIngredients(ctx context.Context, usr *UserSavedRecipe, lang string) error { rows, err := r.pool.Query(ctx, ` SELECT COALESCE(rit.name, ri.name) AS name, ri.amount, ri.unit_code, ri.is_optional 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`, usr.RecipeID, lang) if err != nil { return fmt.Errorf("load ingredients for saved recipe %s: %w", usr.ID, err) } defer rows.Close() usr.Ingredients = []RecipeIngredient{} for rows.Next() { var ing RecipeIngredient if err := rows.Scan(&ing.Name, &ing.Amount, &ing.UnitCode, &ing.IsOptional); err != nil { return err } usr.Ingredients = append(usr.Ingredients, ing) } return rows.Err() } func (r *Repository) loadSteps(ctx context.Context, usr *UserSavedRecipe, lang string) error { rows, err := r.pool.Query(ctx, ` SELECT rs.step_number, COALESCE(rst.description, rs.description) AS description, rs.timer_seconds 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`, usr.RecipeID, lang) if err != nil { return fmt.Errorf("load steps for saved recipe %s: %w", usr.ID, err) } defer rows.Close() usr.Steps = []RecipeStep{} for rows.Next() { var s RecipeStep if err := rows.Scan(&s.StepNumber, &s.Description, &s.TimerSeconds); err != nil { return err } usr.Steps = append(usr.Steps, s) } return rows.Err() } type rowScanner interface { Scan(dest ...any) error } func scanUSR(s rowScanner) (*UserSavedRecipe, error) { var r UserSavedRecipe err := s.Scan( &r.ID, &r.UserID, &r.RecipeID, &r.SavedAt, &r.DishName, &r.Description, &r.ImageURL, &r.CuisineSlug, &r.Difficulty, &r.PrepTimeMin, &r.CookTimeMin, &r.Servings, &r.CaloriesPerServing, &r.ProteinPerServing, &r.FatPerServing, &r.CarbsPerServing, ) return &r, err } // mapCuisineSlug maps a free-form cuisine string (from Gemini) to a known slug. // Falls back to "other". func mapCuisineSlug(cuisine string) string { known := map[string]string{ "russian": "russian", "italian": "italian", "french": "french", "chinese": "chinese", "japanese": "japanese", "korean": "korean", "mexican": "mexican", "mediterranean": "mediterranean", "indian": "indian", "thai": "thai", "american": "american", "georgian": "georgian", "spanish": "spanish", "german": "german", "middle_eastern":"middle_eastern", "turkish": "turkish", "greek": "greek", "vietnamese": "vietnamese", "asian": "other", "european": "other", } if s, ok := known[cuisine]; ok { return s } return "other" } func strVal(v interface{}) string { if s, ok := v.(string); ok { return s } return "" } func floatVal(v interface{}) float64 { switch n := v.(type) { case float64: return n case int: return float64(n) } return 0 }