Files
amcs/internal/store/meals.go
Hein 0eb6ac7ee5 feat(tools): add maintenance and meal planning tools with CRUD operations
- Implement maintenance tool for adding, logging, and retrieving tasks
- Create meals tool for managing recipes, meal plans, and shopping lists
- Introduce reparse metadata tool for updating thought metadata
- Add household knowledge, home maintenance, family calendar, meal planning, and professional CRM database migrations
- Grant necessary permissions for new database tables
2026-03-26 23:29:03 +02:00

290 lines
9.1 KiB
Go

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, &notes, &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, &notes, &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, &notes, &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
}