package diary import ( "context" "errors" "fmt" "github.com/food-ai/backend/internal/infra/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // ErrNotFound is returned when a diary entry does not exist for the user. var ErrNotFound = errors.New("diary entry not found") // Repository handles persistence for meal diary entries. type Repository struct { pool *pgxpool.Pool } // NewRepository creates a new Repository. func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } // ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD). // Supports both dish-based and catalog product-based entries. func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) { lang := locale.FromContext(ctx) rows, err := r.pool.Query(ctx, ` SELECT md.id, md.date::text, md.meal_type, md.portions, md.source, md.dish_id::text, md.product_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, COALESCE(dt.name, d.name, p.canonical_name) AS entry_name, COALESCE( r.calories_per_serving * md.portions, p.calories_per_100g * md.portion_g / 100 ), COALESCE( r.protein_per_serving * md.portions, p.protein_per_100g * md.portion_g / 100 ), COALESCE( r.fat_per_serving * md.portions, p.fat_per_100g * md.portion_g / 100 ), COALESCE( r.carbs_per_serving * md.portions, p.carbs_per_100g * md.portion_g / 100 ) FROM meal_diary md LEFT JOIN dishes d ON d.id = md.dish_id LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 LEFT JOIN recipes r ON r.id = md.recipe_id LEFT JOIN products p ON p.id = md.product_id WHERE md.user_id = $1 AND md.date = $2::date ORDER BY md.created_at ASC`, userID, date, lang) if err != nil { return nil, fmt.Errorf("list diary: %w", err) } defer rows.Close() var result []*Entry for rows.Next() { entry, scanError := scanEntry(rows) if scanError != nil { return nil, fmt.Errorf("scan diary entry: %w", scanError) } result = append(result, entry) } return result, rows.Err() } // Create inserts a new diary entry and returns the stored record (with computed macros). func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) { lang := locale.FromContext(ctx) portions := req.Portions if portions <= 0 { portions = 1 } source := req.Source if source == "" { source = "manual" } var entryID string insertError := r.pool.QueryRow(ctx, ` INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, product_id, recipe_id, portion_g, job_id) VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, userID, req.Date, req.MealType, portions, source, req.DishID, req.ProductID, req.RecipeID, req.PortionG, req.JobID, ).Scan(&entryID) if insertError != nil { return nil, fmt.Errorf("insert diary entry: %w", insertError) } row := r.pool.QueryRow(ctx, ` SELECT md.id, md.date::text, md.meal_type, md.portions, md.source, md.dish_id::text, md.product_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, COALESCE(dt.name, d.name, p.canonical_name) AS entry_name, COALESCE( r.calories_per_serving * md.portions, p.calories_per_100g * md.portion_g / 100 ), COALESCE( r.protein_per_serving * md.portions, p.protein_per_100g * md.portion_g / 100 ), COALESCE( r.fat_per_serving * md.portions, p.fat_per_100g * md.portion_g / 100 ), COALESCE( r.carbs_per_serving * md.portions, p.carbs_per_100g * md.portion_g / 100 ) FROM meal_diary md LEFT JOIN dishes d ON d.id = md.dish_id LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2 LEFT JOIN recipes r ON r.id = md.recipe_id LEFT JOIN products p ON p.id = md.product_id WHERE md.id = $1`, entryID, lang) return scanEntry(row) } // Delete removes a diary entry for the given user. func (r *Repository) Delete(ctx context.Context, id, userID string) error { tag, deleteError := r.pool.Exec(ctx, `DELETE FROM meal_diary WHERE id = $1 AND user_id = $2`, id, userID) if deleteError != nil { return fmt.Errorf("delete diary entry: %w", deleteError) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // --- helpers --- type scannable interface { Scan(dest ...any) error } func scanEntry(s scannable) (*Entry, error) { var entry Entry scanError := s.Scan( &entry.ID, &entry.Date, &entry.MealType, &entry.Portions, &entry.Source, &entry.DishID, &entry.ProductID, &entry.RecipeID, &entry.PortionG, &entry.JobID, &entry.CreatedAt, &entry.Name, &entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG, ) if errors.Is(scanError, pgx.ErrNoRows) { return nil, ErrNotFound } return &entry, scanError }