Backend: - GET /dishes/search — hybrid FTS (english + simple) + trgm + ILIKE search - GET /diary/recent — recently used dishes and products for the current user - product search upgraded: FTS on canonical_name and product_aliases, ranked by GREATEST(ts_rank, similarity) - importoff: collect product_name_ru/de/fr/... as product_aliases for multilingual search (e.g. "сникерс" → "Snickers") - migrations: FTS + trgm indexes merged into 001_initial_schema.sql (002 removed) Flutter: - FoodSearchSheet: debounced search field, recently-used section, product/dish results, scan-photo and barcode chips - DishPortionSheet: quick ½/1/1½/2 buttons + custom input - + button in meal card now opens FoodSearchSheet instead of going directly to AI scan - 7 new l10n keys across all 12 languages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
231 lines
6.7 KiB
Go
231 lines
6.7 KiB
Go
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)
|
|
}
|
|
|
|
// GetRecent returns the most recently logged distinct dishes and products for a user.
|
|
func (r *Repository) GetRecent(ctx context.Context, userID string, limit int) ([]*RecentDiaryItem, error) {
|
|
if limit <= 0 || limit > 20 {
|
|
limit = 10
|
|
}
|
|
lang := locale.FromContext(ctx)
|
|
|
|
const q = `
|
|
WITH recent AS (
|
|
SELECT
|
|
dish_id, product_id,
|
|
MAX(created_at) AS last_used
|
|
FROM meal_diary
|
|
WHERE user_id = $1
|
|
GROUP BY dish_id, product_id
|
|
ORDER BY last_used DESC
|
|
LIMIT $2
|
|
)
|
|
SELECT
|
|
r.dish_id::text,
|
|
r.product_id::text,
|
|
COALESCE(dt.name, d.name, p.canonical_name) AS name,
|
|
d.image_url,
|
|
COALESCE(pct.name, pc.name) AS category_name,
|
|
p.calories_per_100g,
|
|
(SELECT MIN(rec.calories_per_serving)
|
|
FROM recipes rec WHERE rec.dish_id = r.dish_id) AS calories_per_serving
|
|
FROM recent r
|
|
LEFT JOIN dishes d
|
|
ON d.id = r.dish_id
|
|
LEFT JOIN dish_translations dt
|
|
ON dt.dish_id = d.id AND dt.lang = $3
|
|
LEFT JOIN products p
|
|
ON p.id = r.product_id
|
|
LEFT JOIN product_categories pc
|
|
ON pc.slug = p.category
|
|
LEFT JOIN product_category_translations pct
|
|
ON pct.product_category_slug = p.category AND pct.lang = $3
|
|
ORDER BY r.last_used DESC`
|
|
|
|
rows, queryError := r.pool.Query(ctx, q, userID, limit, lang)
|
|
if queryError != nil {
|
|
return nil, fmt.Errorf("get recent diary items: %w", queryError)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []*RecentDiaryItem
|
|
for rows.Next() {
|
|
var item RecentDiaryItem
|
|
var dishID, productID *string
|
|
if scanError := rows.Scan(
|
|
&dishID, &productID,
|
|
&item.Name, &item.ImageURL, &item.CategoryName,
|
|
&item.CaloriesPer100g, &item.CaloriesPerServing,
|
|
); scanError != nil {
|
|
return nil, fmt.Errorf("scan recent diary item: %w", scanError)
|
|
}
|
|
item.DishID = dishID
|
|
item.ProductID = productID
|
|
if dishID != nil {
|
|
item.ItemType = "dish"
|
|
} else {
|
|
item.ItemType = "product"
|
|
}
|
|
results = append(results, &item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// 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
|
|
}
|