package store import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/google/uuid" ext "git.warky.dev/wdevs/amcs/internal/types" ) func (db *DB) AddRecipe(ctx context.Context, r ext.Recipe) (ext.Recipe, error) { ingredients, err := json.Marshal(r.Ingredients) if err != nil { return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err) } instructions, err := json.Marshal(r.Instructions) if err != nil { return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err) } if r.Tags == nil { r.Tags = []string{} } row := db.pool.QueryRow(ctx, ` insert into recipes (name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes) values ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10) returning id, created_at, updated_at `, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings, ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes)) created := r if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil { return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err) } return created, nil } func (db *DB) SearchRecipes(ctx context.Context, query, cuisine string, tags []string, ingredient string) ([]ext.Recipe, error) { args := []any{} conditions := []string{} if q := strings.TrimSpace(query); q != "" { args = append(args, "%"+q+"%") conditions = append(conditions, fmt.Sprintf("name ILIKE $%d", len(args))) } if c := strings.TrimSpace(cuisine); c != "" { args = append(args, c) conditions = append(conditions, fmt.Sprintf("cuisine = $%d", len(args))) } if len(tags) > 0 { args = append(args, tags) conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args))) } if ing := strings.TrimSpace(ingredient); ing != "" { args = append(args, "%"+ing+"%") conditions = append(conditions, fmt.Sprintf("ingredients::text ILIKE $%d", len(args))) } q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes, created_at, updated_at from recipes` if len(conditions) > 0 { q += " where " + strings.Join(conditions, " and ") } q += " order by name" rows, err := db.pool.Query(ctx, q, args...) if err != nil { return nil, fmt.Errorf("search recipes: %w", err) } defer rows.Close() var recipes []ext.Recipe for rows.Next() { r, err := scanRecipeRow(rows) if err != nil { return nil, err } recipes = append(recipes, r) } return recipes, rows.Err() } func (db *DB) GetRecipe(ctx context.Context, id uuid.UUID) (ext.Recipe, error) { row := db.pool.QueryRow(ctx, ` select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes, created_at, updated_at from recipes where id = $1 `, id) var r ext.Recipe var cuisine, notes *string var ingredientsBytes, instructionsBytes []byte if err := row.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings, &ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, ¬es, &r.CreatedAt, &r.UpdatedAt); err != nil { return ext.Recipe{}, fmt.Errorf("get recipe: %w", err) } r.Cuisine = strVal(cuisine) r.Notes = strVal(notes) if r.Tags == nil { r.Tags = []string{} } if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil { r.Ingredients = []ext.Ingredient{} } if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil { r.Instructions = []string{} } return r, nil } func (db *DB) UpdateRecipe(ctx context.Context, id uuid.UUID, r ext.Recipe) (ext.Recipe, error) { ingredients, err := json.Marshal(r.Ingredients) if err != nil { return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err) } instructions, err := json.Marshal(r.Instructions) if err != nil { return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err) } if r.Tags == nil { r.Tags = []string{} } _, err = db.pool.Exec(ctx, ` update recipes set name = $2, cuisine = $3, prep_time_minutes = $4, cook_time_minutes = $5, servings = $6, ingredients = $7::jsonb, instructions = $8::jsonb, tags = $9, rating = $10, notes = $11 where id = $1 `, id, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings, ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes)) if err != nil { return ext.Recipe{}, fmt.Errorf("update recipe: %w", err) } return db.GetRecipe(ctx, id) } func (db *DB) CreateMealPlan(ctx context.Context, weekStart time.Time, entries []ext.MealPlanInput) ([]ext.MealPlanEntry, error) { if _, err := db.pool.Exec(ctx, `delete from meal_plans where week_start = $1`, weekStart); err != nil { return nil, fmt.Errorf("clear meal plan: %w", err) } var results []ext.MealPlanEntry for _, e := range entries { row := db.pool.QueryRow(ctx, ` insert into meal_plans (week_start, day_of_week, meal_type, recipe_id, custom_meal, servings, notes) values ($1, $2, $3, $4, $5, $6, $7) returning id, created_at `, weekStart, e.DayOfWeek, e.MealType, e.RecipeID, nullStr(e.CustomMeal), e.Servings, nullStr(e.Notes)) entry := ext.MealPlanEntry{ WeekStart: weekStart, DayOfWeek: e.DayOfWeek, MealType: e.MealType, RecipeID: e.RecipeID, CustomMeal: e.CustomMeal, Servings: e.Servings, Notes: e.Notes, } if err := row.Scan(&entry.ID, &entry.CreatedAt); err != nil { return nil, fmt.Errorf("insert meal plan entry: %w", err) } results = append(results, entry) } return results, nil } func (db *DB) GetMealPlan(ctx context.Context, weekStart time.Time) ([]ext.MealPlanEntry, error) { rows, err := db.pool.Query(ctx, ` select mp.id, mp.week_start, mp.day_of_week, mp.meal_type, mp.recipe_id, r.name, mp.custom_meal, mp.servings, mp.notes, mp.created_at from meal_plans mp left join recipes r on r.id = mp.recipe_id where mp.week_start = $1 order by case mp.day_of_week when 'monday' then 1 when 'tuesday' then 2 when 'wednesday' then 3 when 'thursday' then 4 when 'friday' then 5 when 'saturday' then 6 when 'sunday' then 7 else 8 end, case mp.meal_type when 'breakfast' then 1 when 'lunch' then 2 when 'dinner' then 3 when 'snack' then 4 else 5 end `, weekStart) if err != nil { return nil, fmt.Errorf("get meal plan: %w", err) } defer rows.Close() var entries []ext.MealPlanEntry for rows.Next() { var e ext.MealPlanEntry var recipeName, customMeal, notes *string if err := rows.Scan(&e.ID, &e.WeekStart, &e.DayOfWeek, &e.MealType, &e.RecipeID, &recipeName, &customMeal, &e.Servings, ¬es, &e.CreatedAt); err != nil { return nil, fmt.Errorf("scan meal plan entry: %w", err) } e.RecipeName = strVal(recipeName) e.CustomMeal = strVal(customMeal) e.Notes = strVal(notes) entries = append(entries, e) } return entries, rows.Err() } func (db *DB) GenerateShoppingList(ctx context.Context, weekStart time.Time) (ext.ShoppingList, error) { entries, err := db.GetMealPlan(ctx, weekStart) if err != nil { return ext.ShoppingList{}, err } recipeIDs := map[uuid.UUID]bool{} for _, e := range entries { if e.RecipeID != nil { recipeIDs[*e.RecipeID] = true } } aggregated := map[string]*ext.ShoppingItem{} for id := range recipeIDs { recipe, err := db.GetRecipe(ctx, id) if err != nil { continue } for _, ing := range recipe.Ingredients { key := strings.ToLower(ing.Name) if existing, ok := aggregated[key]; ok { if ing.Quantity != "" { existing.Quantity += "+" + ing.Quantity } } else { recipeIDCopy := id aggregated[key] = &ext.ShoppingItem{ Name: ing.Name, Quantity: ing.Quantity, Unit: ing.Unit, Purchased: false, RecipeID: &recipeIDCopy, } } } } items := make([]ext.ShoppingItem, 0, len(aggregated)) for _, item := range aggregated { items = append(items, *item) } itemsJSON, err := json.Marshal(items) if err != nil { return ext.ShoppingList{}, fmt.Errorf("marshal shopping items: %w", err) } row := db.pool.QueryRow(ctx, ` insert into shopping_lists (week_start, items) values ($1, $2::jsonb) on conflict (week_start) do update set items = excluded.items, updated_at = now() returning id, created_at, updated_at `, weekStart, itemsJSON) list := ext.ShoppingList{WeekStart: weekStart, Items: items} if err := row.Scan(&list.ID, &list.CreatedAt, &list.UpdatedAt); err != nil { return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err) } return list, nil } func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) { var r ext.Recipe var cuisine, notes *string var ingredientsBytes, instructionsBytes []byte if err := rows.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings, &ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, ¬es, &r.CreatedAt, &r.UpdatedAt); err != nil { return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err) } r.Cuisine = strVal(cuisine) r.Notes = strVal(notes) if r.Tags == nil { r.Tags = []string{} } if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil { r.Ingredients = []ext.Ingredient{} } if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil { r.Instructions = []string{} } return r, nil }