feat: rename ingredients→products, products→user_products; add barcode/OFF import

- Rename catalog: ingredient/* → product/* (canonical_name, barcode, nutrition per 100g)
- Rename pantry: product/* → userproduct/* (user-owned items with expiry)
- Squash migrations into single 001_initial_schema.sql (clean-db baseline)
- product_categories: add English canonical name column; fix COALESCE in queries
- Remove product_translations: product names are stored in their original language
- Add default_unit_name to product API responses via unit_translations JOIN
- Add cmd/importoff: bulk import from OpenFoodFacts JSONL dump (COPY + ON CONFLICT)
- Diary: support product_id entries alongside dish_id (CHECK num_nonnulls = 1)
- Home: getLoggedCalories joins both recipes and catalog products
- Flutter: rename models/providers/services to match backend rename
- Flutter: add barcode scan flow for diary (mobile_scanner, product_portion_sheet)
- Flutter: localise 6 new keys across 12 languages (barcode scan, portion weight)
- Routes: GET /products/search, GET /products/barcode/{barcode}, /user-products

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-21 12:45:48 +02:00
parent 6861e5e754
commit 205edbdade
72 changed files with 2588 additions and 1444 deletions

View File

@@ -0,0 +1,359 @@
package main
import (
"bufio"
"compress/gzip"
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"github.com/jackc/pgx/v5"
)
// offRecord is the JSON shape of one line in the OpenFoodFacts JSONL dump.
type offRecord struct {
Code string `json:"code"`
ProductName string `json:"product_name"`
ProductNameEN string `json:"product_name_en"`
CategoriesTags []string `json:"categories_tags"`
Nutriments offNutriments `json:"nutriments"`
UniqueScansN int `json:"unique_scans_n"`
}
type offNutriments struct {
EnergyKcal100g *float64 `json:"energy-kcal_100g"`
Proteins100g *float64 `json:"proteins_100g"`
Fat100g *float64 `json:"fat_100g"`
Carbohydrates100g *float64 `json:"carbohydrates_100g"`
Fiber100g *float64 `json:"fiber_100g"`
}
type productImportRow struct {
canonicalName string
barcode string
category *string
calories float64
protein *float64
fat *float64
carbs *float64
fiber *float64
}
// categoryPrefixes maps OpenFoodFacts category tag prefixes to our product_categories slugs.
// Entries are checked in order; the first match wins.
var categoryPrefixes = []struct {
prefix string
slug string
}{
{"en:dairies", "dairy"},
{"en:dairy-products", "dairy"},
{"en:meats", "meat"},
{"en:poultry", "meat"},
{"en:fish-and-seafood", "meat"},
{"en:fruits", "produce"},
{"en:vegetables", "produce"},
{"en:plant-based-foods", "produce"},
{"en:breads", "bakery"},
{"en:pastries", "bakery"},
{"en:baked-goods", "bakery"},
{"en:frozen-foods", "frozen"},
{"en:beverages", "beverages"},
{"en:drinks", "beverages"},
{"en:sodas", "beverages"},
{"en:waters", "beverages"},
}
func resolveCategory(categoriesTags []string) *string {
for _, tag := range categoriesTags {
for _, mapping := range categoryPrefixes {
if strings.HasPrefix(tag, mapping.prefix) {
slug := mapping.slug
return &slug
}
}
}
other := "other"
return &other
}
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
if runError := run(); runError != nil {
slog.Error("import failed", "error", runError)
os.Exit(1)
}
}
func run() error {
filePathFlag := flag.String("file", "", "path to OFF JSONL or JSONL.GZ file (required)")
dsnFlag := flag.String("dsn", os.Getenv("DATABASE_URL"), "postgres connection DSN")
limitFlag := flag.Int("limit", 0, "stop after N accepted products (0 = no limit)")
batchSizeFlag := flag.Int("batch", 500, "products per upsert batch")
minScansFlag := flag.Int("min-scans", 1, "minimum unique_scans_n to include a product (0 = no filter)")
flag.Parse()
if *filePathFlag == "" {
return fmt.Errorf("-file is required")
}
if *dsnFlag == "" {
return fmt.Errorf("-dsn or DATABASE_URL is required")
}
importContext, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
pgxConn, connectError := pgx.Connect(importContext, *dsnFlag)
if connectError != nil {
return fmt.Errorf("connect to database: %w", connectError)
}
defer pgxConn.Close(importContext)
slog.Info("connected to database")
// Create a temporary staging table for the COPY step.
// It has no unique constraints so COPY never fails on duplicate data.
if _, createError := pgxConn.Exec(importContext, `
CREATE TEMP TABLE IF NOT EXISTS products_import (
canonical_name TEXT,
barcode TEXT,
category TEXT,
calories_per_100g DOUBLE PRECISION,
protein_per_100g DOUBLE PRECISION,
fat_per_100g DOUBLE PRECISION,
carbs_per_100g DOUBLE PRECISION,
fiber_per_100g DOUBLE PRECISION
)`); createError != nil {
return fmt.Errorf("create staging table: %w", createError)
}
dataFile, openError := os.Open(*filePathFlag)
if openError != nil {
return fmt.Errorf("open file: %w", openError)
}
defer dataFile.Close()
var lineScanner *bufio.Scanner
if strings.HasSuffix(*filePathFlag, ".gz") {
gzipReader, gzipError := gzip.NewReader(dataFile)
if gzipError != nil {
return fmt.Errorf("open gzip reader: %w", gzipError)
}
defer gzipReader.Close()
lineScanner = bufio.NewScanner(gzipReader)
} else {
lineScanner = bufio.NewScanner(dataFile)
}
// OFF lines can exceed the default 64 KB scanner buffer.
lineScanner.Buffer(make([]byte, 16*1024*1024), 16*1024*1024)
var (
productBatch []productImportRow
seenInBatch = make(map[string]bool, *batchSizeFlag)
totalAccepted int
totalInserted int64
totalSkipped int
)
flushCurrent := func() error {
inserted, flushError := flushBatch(importContext, pgxConn, productBatch)
if flushError != nil {
return flushError
}
totalInserted += inserted
productBatch = productBatch[:0]
for key := range seenInBatch {
delete(seenInBatch, key)
}
slog.Info("progress",
"accepted", totalAccepted,
"inserted", totalInserted,
"skipped", totalSkipped,
)
return nil
}
for lineScanner.Scan() {
if importContext.Err() != nil {
break
}
var record offRecord
if decodeError := json.Unmarshal(lineScanner.Bytes(), &record); decodeError != nil {
totalSkipped++
continue
}
// Resolve canonical name: prefer English, fall back to any language.
canonicalName := record.ProductNameEN
if canonicalName == "" {
canonicalName = record.ProductName
}
// Apply filter rules.
if record.Code == "" || canonicalName == "" {
totalSkipped++
continue
}
if record.Nutriments.EnergyKcal100g == nil || *record.Nutriments.EnergyKcal100g <= 0 {
totalSkipped++
continue
}
if *minScansFlag > 0 && record.UniqueScansN < *minScansFlag {
totalSkipped++
continue
}
// Deduplicate by canonical_name within the current batch.
if seenInBatch[canonicalName] {
totalSkipped++
continue
}
seenInBatch[canonicalName] = true
totalAccepted++
productBatch = append(productBatch, productImportRow{
canonicalName: canonicalName,
barcode: record.Code,
category: resolveCategory(record.CategoriesTags),
calories: *record.Nutriments.EnergyKcal100g,
protein: record.Nutriments.Proteins100g,
fat: record.Nutriments.Fat100g,
carbs: record.Nutriments.Carbohydrates100g,
fiber: record.Nutriments.Fiber100g,
})
if len(productBatch) >= *batchSizeFlag {
if flushError := flushCurrent(); flushError != nil {
return flushError
}
}
if *limitFlag > 0 && totalAccepted >= *limitFlag {
break
}
}
if scanError := lineScanner.Err(); scanError != nil {
return fmt.Errorf("read file: %w", scanError)
}
// Flush any remaining rows.
if len(productBatch) > 0 {
if flushError := flushCurrent(); flushError != nil {
return flushError
}
}
slog.Info("import complete",
"accepted", totalAccepted,
"inserted", totalInserted,
"skipped", totalSkipped,
)
return nil
}
// flushBatch upserts productBatch into the catalog.
// It uses a staging table + COPY for fast bulk loading, then INSERT … SELECT with
// ON CONFLICT to safely merge into products.
func flushBatch(
requestContext context.Context,
pgxConn *pgx.Conn,
productBatch []productImportRow,
) (int64, error) {
if len(productBatch) == 0 {
return 0, nil
}
// Truncate staging table so it is empty before each batch.
if _, truncateError := pgxConn.Exec(requestContext, `TRUNCATE products_import`); truncateError != nil {
return 0, fmt.Errorf("truncate staging table: %w", truncateError)
}
// COPY product rows into the staging table.
// No unique constraints here, so the COPY never errors on duplicate data.
_, copyError := pgxConn.CopyFrom(
requestContext,
pgx.Identifier{"products_import"},
[]string{
"canonical_name", "barcode", "category",
"calories_per_100g", "protein_per_100g", "fat_per_100g",
"carbs_per_100g", "fiber_per_100g",
},
pgx.CopyFromSlice(len(productBatch), func(rowIndex int) ([]any, error) {
row := productBatch[rowIndex]
return []any{
row.canonicalName, row.barcode, row.category,
row.calories, row.protein, row.fat,
row.carbs, row.fiber,
}, nil
}),
)
if copyError != nil {
return 0, fmt.Errorf("copy products to staging table: %w", copyError)
}
// Nullify any barcode in the staging table that is already assigned to a
// different product in the catalog. This prevents unique-constraint violations
// on the barcode column during the INSERT below.
if _, updateError := pgxConn.Exec(requestContext, `
UPDATE products_import pi
SET barcode = NULL
WHERE pi.barcode IS NOT NULL
AND EXISTS (
SELECT 1 FROM products p
WHERE p.barcode = pi.barcode
)`); updateError != nil {
return 0, fmt.Errorf("nullify conflicting barcodes: %w", updateError)
}
// Insert from staging into the catalog.
// ON CONFLICT (canonical_name): update nutritional columns but leave barcode
// unchanged — we do not want to reassign a barcode that already belongs to this
// canonical entry, nor steal one from another entry.
upsertRows, upsertError := pgxConn.Query(requestContext, `
INSERT INTO products (
canonical_name, barcode, category,
calories_per_100g, protein_per_100g, fat_per_100g,
carbs_per_100g, fiber_per_100g
)
SELECT
canonical_name, barcode, category,
calories_per_100g, protein_per_100g, fat_per_100g,
carbs_per_100g, fiber_per_100g
FROM products_import
ON CONFLICT (canonical_name) DO UPDATE SET
category = COALESCE(EXCLUDED.category, products.category),
calories_per_100g = COALESCE(EXCLUDED.calories_per_100g, products.calories_per_100g),
protein_per_100g = COALESCE(EXCLUDED.protein_per_100g, products.protein_per_100g),
fat_per_100g = COALESCE(EXCLUDED.fat_per_100g, products.fat_per_100g),
carbs_per_100g = COALESCE(EXCLUDED.carbs_per_100g, products.carbs_per_100g),
fiber_per_100g = COALESCE(EXCLUDED.fiber_per_100g, products.fiber_per_100g),
updated_at = now()
RETURNING id`)
if upsertError != nil {
return 0, fmt.Errorf("upsert products: %w", upsertError)
}
var insertedCount int64
for upsertRows.Next() {
var productID string
if scanError := upsertRows.Scan(&productID); scanError != nil {
upsertRows.Close()
return 0, fmt.Errorf("scan upserted product id: %w", scanError)
}
insertedCount++
}
upsertRows.Close()
if rowsError := upsertRows.Err(); rowsError != nil {
return 0, fmt.Errorf("iterate upsert results: %w", rowsError)
}
return insertedCount, nil
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/food-ai/backend/internal/domain/diary" "github.com/food-ai/backend/internal/domain/diary"
"github.com/food-ai/backend/internal/domain/dish" "github.com/food-ai/backend/internal/domain/dish"
"github.com/food-ai/backend/internal/domain/home" "github.com/food-ai/backend/internal/domain/home"
"github.com/food-ai/backend/internal/domain/ingredient"
"github.com/food-ai/backend/internal/domain/menu" "github.com/food-ai/backend/internal/domain/menu"
"github.com/food-ai/backend/internal/domain/product" "github.com/food-ai/backend/internal/domain/product"
"github.com/food-ai/backend/internal/domain/recipe" "github.com/food-ai/backend/internal/domain/recipe"
@@ -14,6 +13,7 @@ import (
"github.com/food-ai/backend/internal/domain/recommendation" "github.com/food-ai/backend/internal/domain/recommendation"
"github.com/food-ai/backend/internal/domain/savedrecipe" "github.com/food-ai/backend/internal/domain/savedrecipe"
"github.com/food-ai/backend/internal/domain/user" "github.com/food-ai/backend/internal/domain/user"
"github.com/food-ai/backend/internal/domain/userproduct"
"github.com/food-ai/backend/internal/infra/config" "github.com/food-ai/backend/internal/infra/config"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -37,14 +37,15 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) {
openaiClient := newOpenAIClient(mainGeminiAPIKey) openaiClient := newOpenAIClient(mainGeminiAPIKey)
mainPexelsAPIKey := newPexelsAPIKey(appConfig) mainPexelsAPIKey := newPexelsAPIKey(appConfig)
pexelsClient := newPexelsClient(mainPexelsAPIKey) pexelsClient := newPexelsClient(mainPexelsAPIKey)
productRepository := product.NewRepository(pool) userProductRepository := userproduct.NewRepository(pool)
recommendationHandler := recommendation.NewHandler(openaiClient, pexelsClient, userRepository, productRepository) recommendationHandler := recommendation.NewHandler(openaiClient, pexelsClient, userRepository, userProductRepository)
dishRepository := dish.NewRepository(pool) dishRepository := dish.NewRepository(pool)
savedrecipeRepository := savedrecipe.NewRepository(pool, dishRepository) savedrecipeRepository := savedrecipe.NewRepository(pool, dishRepository)
savedrecipeHandler := savedrecipe.NewHandler(savedrecipeRepository) savedrecipeHandler := savedrecipe.NewHandler(savedrecipeRepository)
ingredientRepository := ingredient.NewRepository(pool) productRepository := product.NewRepository(pool)
ingredientHandler := ingredient.NewHandler(ingredientRepository) openFoodFactsClient := product.NewOpenFoodFacts()
productHandler := product.NewHandler(productRepository) productHandler := product.NewHandler(productRepository, openFoodFactsClient)
userProductHandler := userproduct.NewHandler(userProductRepository)
// Kafka producer // Kafka producer
kafkaProducer, kafkaProducerError := newKafkaProducer(appConfig) kafkaProducer, kafkaProducerError := newKafkaProducer(appConfig)
@@ -55,10 +56,10 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) {
// Recognition pipeline // Recognition pipeline
jobRepository := recognition.NewJobRepository(pool) jobRepository := recognition.NewJobRepository(pool)
sseBroker := recognition.NewSSEBroker(pool, jobRepository) sseBroker := recognition.NewSSEBroker(pool, jobRepository)
recognitionHandler := recognition.NewHandler(openaiClient, ingredientRepository, jobRepository, kafkaProducer, sseBroker) recognitionHandler := recognition.NewHandler(openaiClient, productRepository, jobRepository, kafkaProducer, sseBroker)
menuRepository := menu.NewRepository(pool) menuRepository := menu.NewRepository(pool)
menuHandler := menu.NewHandler(menuRepository, openaiClient, pexelsClient, userRepository, productRepository, dishRepository) menuHandler := menu.NewHandler(menuRepository, openaiClient, pexelsClient, userRepository, userProductRepository, dishRepository)
diaryRepository := diary.NewRepository(pool) diaryRepository := diary.NewRepository(pool)
diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository) diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository)
homeHandler := home.NewHandler(pool) homeHandler := home.NewHandler(pool)
@@ -77,8 +78,8 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) {
userHandler, userHandler,
recommendationHandler, recommendationHandler,
savedrecipeHandler, savedrecipeHandler,
ingredientHandler,
productHandler, productHandler,
userProductHandler,
recognitionHandler, recognitionHandler,
menuHandler, menuHandler,
diaryHandler, diaryHandler,

View File

@@ -11,7 +11,6 @@ import (
"github.com/food-ai/backend/internal/adapters/kafka" "github.com/food-ai/backend/internal/adapters/kafka"
"github.com/food-ai/backend/internal/adapters/openai" "github.com/food-ai/backend/internal/adapters/openai"
"github.com/food-ai/backend/internal/domain/home" "github.com/food-ai/backend/internal/domain/home"
"github.com/food-ai/backend/internal/domain/ingredient"
"github.com/food-ai/backend/internal/domain/menu" "github.com/food-ai/backend/internal/domain/menu"
"github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/infra/middleware"
"github.com/food-ai/backend/internal/adapters/pexels" "github.com/food-ai/backend/internal/adapters/pexels"
@@ -25,6 +24,7 @@ import (
"github.com/food-ai/backend/internal/domain/tag" "github.com/food-ai/backend/internal/domain/tag"
"github.com/food-ai/backend/internal/domain/units" "github.com/food-ai/backend/internal/domain/units"
"github.com/food-ai/backend/internal/domain/user" "github.com/food-ai/backend/internal/domain/user"
"github.com/food-ai/backend/internal/domain/userproduct"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -125,8 +125,8 @@ func newRouter(
userHandler *user.Handler, userHandler *user.Handler,
recommendationHandler *recommendation.Handler, recommendationHandler *recommendation.Handler,
savedRecipeHandler *savedrecipe.Handler, savedRecipeHandler *savedrecipe.Handler,
ingredientHandler *ingredient.Handler,
productHandler *product.Handler, productHandler *product.Handler,
userProductHandler *userproduct.Handler,
recognitionHandler *recognition.Handler, recognitionHandler *recognition.Handler,
menuHandler *menu.Handler, menuHandler *menu.Handler,
diaryHandler *diary.Handler, diaryHandler *diary.Handler,
@@ -145,8 +145,8 @@ func newRouter(
userHandler, userHandler,
recommendationHandler, recommendationHandler,
savedRecipeHandler, savedRecipeHandler,
ingredientHandler,
productHandler, productHandler,
userProductHandler,
recognitionHandler, recognitionHandler,
menuHandler, menuHandler,
diaryHandler, diaryHandler,
@@ -204,12 +204,12 @@ func newKafkaProducer(appConfig *config.Config) (*kafka.Producer, error) {
var _ middleware.AccessTokenValidator = (*jwtAdapter)(nil) var _ middleware.AccessTokenValidator = (*jwtAdapter)(nil)
var _ menu.PhotoSearcher = (*pexels.Client)(nil) var _ menu.PhotoSearcher = (*pexels.Client)(nil)
var _ menu.UserLoader = (*user.Repository)(nil) var _ menu.UserLoader = (*user.Repository)(nil)
var _ menu.ProductLister = (*product.Repository)(nil) var _ menu.ProductLister = (*userproduct.Repository)(nil)
var _ menu.RecipeSaver = (*dish.Repository)(nil) var _ menu.RecipeSaver = (*dish.Repository)(nil)
var _ recommendation.PhotoSearcher = (*pexels.Client)(nil) var _ recommendation.PhotoSearcher = (*pexels.Client)(nil)
var _ recommendation.UserLoader = (*user.Repository)(nil) var _ recommendation.UserLoader = (*user.Repository)(nil)
var _ recommendation.ProductLister = (*product.Repository)(nil) var _ recommendation.ProductLister = (*userproduct.Repository)(nil)
var _ recognition.IngredientRepository = (*ingredient.Repository)(nil) var _ recognition.ProductRepository = (*product.Repository)(nil)
var _ recognition.KafkaPublisher = (*kafka.Producer)(nil) var _ recognition.KafkaPublisher = (*kafka.Producer)(nil)
var _ recognition.JobRepository = (*recognition.PostgresJobRepository)(nil) var _ recognition.JobRepository = (*recognition.PostgresJobRepository)(nil)
var _ user.UserRepository = (*user.Repository)(nil) var _ user.UserRepository = (*user.Repository)(nil)

View File

@@ -7,14 +7,15 @@ type Entry struct {
ID string `json:"id"` ID string `json:"id"`
Date string `json:"date"` // YYYY-MM-DD Date string `json:"date"` // YYYY-MM-DD
MealType string `json:"meal_type"` MealType string `json:"meal_type"`
Name string `json:"name"` // from dishes JOIN Name string `json:"name"` // from dishes or products JOIN
Portions float64 `json:"portions"` Portions float64 `json:"portions"`
Calories *float64 `json:"calories,omitempty"` // recipe.calories_per_serving * portions Calories *float64 `json:"calories,omitempty"` // recipe.calories_per_serving * portions, or product * portion_g
ProteinG *float64 `json:"protein_g,omitempty"` ProteinG *float64 `json:"protein_g,omitempty"`
FatG *float64 `json:"fat_g,omitempty"` FatG *float64 `json:"fat_g,omitempty"`
CarbsG *float64 `json:"carbs_g,omitempty"` CarbsG *float64 `json:"carbs_g,omitempty"`
Source string `json:"source"` Source string `json:"source"`
DishID string `json:"dish_id"` DishID *string `json:"dish_id,omitempty"`
ProductID *string `json:"product_id,omitempty"`
RecipeID *string `json:"recipe_id,omitempty"` RecipeID *string `json:"recipe_id,omitempty"`
PortionG *float64 `json:"portion_g,omitempty"` PortionG *float64 `json:"portion_g,omitempty"`
JobID *string `json:"job_id,omitempty"` JobID *string `json:"job_id,omitempty"`
@@ -23,13 +24,14 @@ type Entry struct {
// CreateRequest is the body for POST /diary. // CreateRequest is the body for POST /diary.
type CreateRequest struct { type CreateRequest struct {
Date string `json:"date"` Date string `json:"date"`
MealType string `json:"meal_type"` MealType string `json:"meal_type"`
Name string `json:"name"` // input-only; used if DishID is nil Name string `json:"name"` // input-only; used if DishID is nil and ProductID is nil
Portions float64 `json:"portions"` Portions float64 `json:"portions"`
Source string `json:"source"` Source string `json:"source"`
DishID *string `json:"dish_id"` DishID *string `json:"dish_id"`
RecipeID *string `json:"recipe_id"` ProductID *string `json:"product_id"`
PortionG *float64 `json:"portion_g"` RecipeID *string `json:"recipe_id"`
JobID *string `json:"job_id"` PortionG *float64 `json:"portion_g"`
JobID *string `json:"job_id"`
} }

View File

@@ -82,29 +82,32 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "date and meal_type are required") writeError(w, http.StatusBadRequest, "date and meal_type are required")
return return
} }
if req.DishID == nil && req.Name == "" { if req.DishID == nil && req.ProductID == nil && req.Name == "" {
writeError(w, http.StatusBadRequest, "dish_id or name is required") writeError(w, http.StatusBadRequest, "dish_id, product_id, or name is required")
return return
} }
if req.DishID == nil { // Product-based entry: skip dish/recipe resolution entirely.
dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name) if req.ProductID == nil {
if resolveError != nil { if req.DishID == nil {
slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError) dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name)
writeError(w, http.StatusInternalServerError, "failed to resolve dish") if resolveError != nil {
return slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError)
writeError(w, http.StatusInternalServerError, "failed to resolve dish")
return
}
req.DishID = &dishID
} }
req.DishID = &dishID
}
if req.RecipeID == nil { if req.RecipeID == nil {
recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0) recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0)
if recipeError != nil { if recipeError != nil {
slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError) slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError)
writeError(w, http.StatusInternalServerError, "failed to resolve recipe") writeError(w, http.StatusInternalServerError, "failed to resolve recipe")
return return
}
req.RecipeID = &recipeID
} }
req.RecipeID = &recipeID
} }
entry, createError := h.repo.Create(r.Context(), userID, req) entry, createError := h.repo.Create(r.Context(), userID, req)

View File

@@ -24,22 +24,35 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
} }
// ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD). // ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD).
// Dish name and macros are computed via JOIN with dishes and recipes. // Supports both dish-based and catalog product-based entries.
func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) { func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) {
lang := locale.FromContext(ctx) lang := locale.FromContext(ctx)
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT SELECT
md.id, md.date::text, md.meal_type, md.portions, md.id, md.date::text, md.meal_type, md.portions,
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, 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) AS dish_name, COALESCE(dt.name, d.name, p.canonical_name) AS entry_name,
r.calories_per_serving * md.portions, COALESCE(
r.protein_per_serving * md.portions, r.calories_per_serving * md.portions,
r.fat_per_serving * md.portions, p.calories_per_100g * md.portion_g / 100
r.carbs_per_serving * md.portions ),
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 FROM meal_diary md
JOIN dishes d ON d.id = md.dish_id 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 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 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 WHERE md.user_id = $1 AND md.date = $2::date
ORDER BY md.created_at ASC`, userID, date, lang) ORDER BY md.created_at ASC`, userID, date, lang)
if err != nil { if err != nil {
@@ -72,10 +85,10 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
var entryID string var entryID string
insertError := r.pool.QueryRow(ctx, ` insertError := r.pool.QueryRow(ctx, `
INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g, job_id) 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) VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`, RETURNING id`,
userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG, req.JobID, userID, req.Date, req.MealType, portions, source, req.DishID, req.ProductID, req.RecipeID, req.PortionG, req.JobID,
).Scan(&entryID) ).Scan(&entryID)
if insertError != nil { if insertError != nil {
return nil, fmt.Errorf("insert diary entry: %w", insertError) return nil, fmt.Errorf("insert diary entry: %w", insertError)
@@ -84,16 +97,29 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
row := r.pool.QueryRow(ctx, ` row := r.pool.QueryRow(ctx, `
SELECT SELECT
md.id, md.date::text, md.meal_type, md.portions, md.id, md.date::text, md.meal_type, md.portions,
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, 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) AS dish_name, COALESCE(dt.name, d.name, p.canonical_name) AS entry_name,
r.calories_per_serving * md.portions, COALESCE(
r.protein_per_serving * md.portions, r.calories_per_serving * md.portions,
r.fat_per_serving * md.portions, p.calories_per_100g * md.portion_g / 100
r.carbs_per_serving * md.portions ),
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 FROM meal_diary md
JOIN dishes d ON d.id = md.dish_id 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 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 recipes r ON r.id = md.recipe_id
LEFT JOIN products p ON p.id = md.product_id
WHERE md.id = $1`, entryID, lang) WHERE md.id = $1`, entryID, lang)
return scanEntry(row) return scanEntry(row)
} }
@@ -121,7 +147,7 @@ func scanEntry(s scannable) (*Entry, error) {
var entry Entry var entry Entry
scanError := s.Scan( scanError := s.Scan(
&entry.ID, &entry.Date, &entry.MealType, &entry.Portions, &entry.ID, &entry.Date, &entry.MealType, &entry.Portions,
&entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.JobID, &entry.CreatedAt, &entry.Source, &entry.DishID, &entry.ProductID, &entry.RecipeID, &entry.PortionG, &entry.JobID, &entry.CreatedAt,
&entry.Name, &entry.Name,
&entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG, &entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG,
) )

View File

@@ -76,12 +76,21 @@ func (h *Handler) getDailyGoal(ctx context.Context, userID string) int {
} }
// getLoggedCalories returns total calories logged in meal_diary for today. // getLoggedCalories returns total calories logged in meal_diary for today.
// Supports both recipe-based entries (via recipes JOIN) and catalog product entries (via products JOIN).
func (h *Handler) getLoggedCalories(ctx context.Context, userID, date string) float64 { func (h *Handler) getLoggedCalories(ctx context.Context, userID, date string) float64 {
var total float64 var total float64
_ = h.pool.QueryRow(ctx, _ = h.pool.QueryRow(ctx,
`SELECT COALESCE(SUM(calories * portions), 0) `SELECT COALESCE(SUM(
FROM meal_diary COALESCE(
WHERE user_id = $1 AND date::text = $2`, r.calories_per_serving * md.portions,
p.calories_per_100g * md.portion_g / 100,
0
)
), 0)
FROM meal_diary md
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::text = $2`,
userID, date, userID, date,
).Scan(&total) ).Scan(&total)
return total return total
@@ -153,7 +162,7 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring
WITH p AS ( WITH p AS (
SELECT name, quantity, unit, SELECT name, quantity, unit,
(added_at + storage_days * INTERVAL '1 day') AS expires_at (added_at + storage_days * INTERVAL '1 day') AS expires_at
FROM products FROM user_products
WHERE user_id = $1 WHERE user_id = $1
) )
SELECT name, quantity, unit, SELECT name, quantity, unit,

View File

@@ -1,29 +0,0 @@
package ingredient
import (
"encoding/json"
"time"
)
// IngredientMapping is the canonical ingredient record used to link
// user products and recipe ingredients.
// CanonicalName holds the content for the language resolved at query time
// (English by default, or from ingredient_translations when available).
type IngredientMapping struct {
ID string `json:"id"`
CanonicalName string `json:"canonical_name"`
Aliases json.RawMessage `json:"aliases"` // []string, populated by read queries
Category *string `json:"category"`
CategoryName *string `json:"category_name"` // localized category display name
DefaultUnit *string `json:"default_unit"`
CaloriesPer100g *float64 `json:"calories_per_100g"`
ProteinPer100g *float64 `json:"protein_per_100g"`
FatPer100g *float64 `json:"fat_per_100g"`
CarbsPer100g *float64 `json:"carbs_per_100g"`
FiberPer100g *float64 `json:"fiber_per_100g"`
StorageDays *int `json:"storage_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -1,57 +0,0 @@
package ingredient
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
)
// IngredientSearcher is the data layer interface used by Handler.
type IngredientSearcher interface {
Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error)
}
// Handler handles ingredient HTTP requests.
type Handler struct {
repo IngredientSearcher
}
// NewHandler creates a new Handler.
func NewHandler(repo IngredientSearcher) *Handler {
return &Handler{repo: repo}
}
// Search handles GET /ingredients/search?q=&limit=10.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q == "" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("[]"))
return
}
limit := 10
if s := r.URL.Query().Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 50 {
limit = n
}
}
mappings, err := h.repo.Search(r.Context(), q, limit)
if err != nil {
slog.Error("search ingredients", "q", q, "err", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"search failed"}`))
return
}
if mappings == nil {
mappings = []*IngredientMapping{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mappings)
}

View File

@@ -1,301 +0,0 @@
package ingredient
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/food-ai/backend/internal/infra/locale"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Repository handles persistence for ingredients and their translations.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// Upsert inserts or updates an ingredient (English canonical content).
// Conflict is resolved on canonical_name.
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
query := `
INSERT INTO ingredients (
canonical_name,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (canonical_name) DO UPDATE SET
category = EXCLUDED.category,
default_unit = EXCLUDED.default_unit,
calories_per_100g = EXCLUDED.calories_per_100g,
protein_per_100g = EXCLUDED.protein_per_100g,
fat_per_100g = EXCLUDED.fat_per_100g,
carbs_per_100g = EXCLUDED.carbs_per_100g,
fiber_per_100g = EXCLUDED.fiber_per_100g,
storage_days = EXCLUDED.storage_days,
updated_at = now()
RETURNING id, canonical_name, category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at`
row := r.pool.QueryRow(ctx, query,
m.CanonicalName,
m.Category, m.DefaultUnit,
m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g,
m.StorageDays,
)
return scanMappingWrite(row)
}
// GetByID returns an ingredient by UUID.
// CanonicalName and aliases are resolved for the language stored in ctx.
// Returns nil, nil if not found.
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
lang := locale.FromContext(ctx)
query := `
SELECT im.id,
COALESCE(it.name, im.canonical_name) AS canonical_name,
im.category,
COALESCE(ict.name, im.category) AS category_name,
im.default_unit,
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
im.storage_days, im.created_at, im.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases
FROM ingredients im
LEFT JOIN ingredient_translations it
ON it.ingredient_id = im.id AND it.lang = $2
LEFT JOIN ingredient_category_translations ict
ON ict.category_slug = im.category AND ict.lang = $2
LEFT JOIN LATERAL (
SELECT json_agg(ia.alias ORDER BY ia.alias) AS aliases
FROM ingredient_aliases ia
WHERE ia.ingredient_id = im.id AND ia.lang = $2
) al ON true
WHERE im.id = $1`
row := r.pool.QueryRow(ctx, query, id, lang)
m, err := scanMappingRead(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return m, err
}
// FuzzyMatch finds the single best matching ingredient for a given name.
// Searches both English and translated names for the language in ctx.
// Returns nil, nil when no match is found.
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
results, err := r.Search(ctx, name, 1)
if err != nil {
return nil, err
}
if len(results) == 0 {
return nil, nil
}
return results[0], nil
}
// Search finds ingredients matching the query string.
// Searches aliases table and translated names for the language in ctx.
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
if limit <= 0 {
limit = 10
}
lang := locale.FromContext(ctx)
q := `
SELECT im.id,
COALESCE(it.name, im.canonical_name) AS canonical_name,
im.category,
COALESCE(ict.name, im.category) AS category_name,
im.default_unit,
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
im.storage_days, im.created_at, im.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases
FROM ingredients im
LEFT JOIN ingredient_translations it
ON it.ingredient_id = im.id AND it.lang = $3
LEFT JOIN ingredient_category_translations ict
ON ict.category_slug = im.category AND ict.lang = $3
LEFT JOIN LATERAL (
SELECT json_agg(ia.alias ORDER BY ia.alias) AS aliases
FROM ingredient_aliases ia
WHERE ia.ingredient_id = im.id AND ia.lang = $3
) al ON true
WHERE EXISTS (
SELECT 1 FROM ingredient_aliases ia
WHERE ia.ingredient_id = im.id
AND (ia.lang = $3 OR ia.lang = 'en')
AND ia.alias ILIKE '%' || $1 || '%'
)
OR im.canonical_name ILIKE '%' || $1 || '%'
OR it.name ILIKE '%' || $1 || '%'
OR similarity(COALESCE(it.name, im.canonical_name), $1) > 0.3
ORDER BY similarity(COALESCE(it.name, im.canonical_name), $1) DESC
LIMIT $2`
rows, err := r.pool.Query(ctx, q, query, limit, lang)
if err != nil {
return nil, fmt.Errorf("search ingredients: %w", err)
}
defer rows.Close()
return collectMappingsRead(rows)
}
// Count returns the total number of ingredients.
func (r *Repository) Count(ctx context.Context) (int, error) {
var n int
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM ingredients`).Scan(&n); err != nil {
return 0, fmt.Errorf("count ingredients: %w", err)
}
return n, nil
}
// ListMissingTranslation returns ingredients that have no translation for the
// given language, ordered by id.
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*IngredientMapping, error) {
query := `
SELECT im.id, im.canonical_name,
im.category, im.default_unit,
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
im.storage_days, im.created_at, im.updated_at
FROM ingredients im
WHERE NOT EXISTS (
SELECT 1 FROM ingredient_translations it
WHERE it.ingredient_id = im.id AND it.lang = $3
)
ORDER BY im.id
LIMIT $1 OFFSET $2`
rows, err := r.pool.Query(ctx, query, limit, offset, lang)
if err != nil {
return nil, fmt.Errorf("list missing translation (%s): %w", lang, err)
}
defer rows.Close()
return collectMappingsWrite(rows)
}
// UpsertTranslation inserts or replaces a name translation for an ingredient.
func (r *Repository) UpsertTranslation(ctx context.Context, id, lang, name string) error {
query := `
INSERT INTO ingredient_translations (ingredient_id, lang, name)
VALUES ($1, $2, $3)
ON CONFLICT (ingredient_id, lang) DO UPDATE SET name = EXCLUDED.name`
if _, err := r.pool.Exec(ctx, query, id, lang, name); err != nil {
return fmt.Errorf("upsert ingredient translation %s/%s: %w", id, lang, err)
}
return nil
}
// UpsertAliases inserts aliases for a given ingredient and language.
// Each alias is inserted with ON CONFLICT DO NOTHING, so duplicates are skipped.
func (r *Repository) UpsertAliases(ctx context.Context, id, lang string, aliases []string) error {
if len(aliases) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, alias := range aliases {
batch.Queue(
`INSERT INTO ingredient_aliases (ingredient_id, lang, alias) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
id, lang, alias,
)
}
results := r.pool.SendBatch(ctx, batch)
defer results.Close()
for range aliases {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("upsert ingredient alias %s/%s: %w", id, lang, err)
}
}
return nil
}
// UpsertCategoryTranslation inserts or replaces a localized category name.
func (r *Repository) UpsertCategoryTranslation(ctx context.Context, slug, lang, name string) error {
query := `
INSERT INTO ingredient_category_translations (category_slug, lang, name)
VALUES ($1, $2, $3)
ON CONFLICT (category_slug, lang) DO UPDATE SET name = EXCLUDED.name`
if _, err := r.pool.Exec(ctx, query, slug, lang, name); err != nil {
return fmt.Errorf("upsert category translation %s/%s: %w", slug, lang, err)
}
return nil
}
// --- scan helpers ---
// scanMappingWrite scans rows from Upsert / ListMissingTranslation queries
// (no aliases lateral join, no category_name).
func scanMappingWrite(row pgx.Row) (*IngredientMapping, error) {
var m IngredientMapping
err := row.Scan(
&m.ID, &m.CanonicalName, &m.Category, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, err
}
m.Aliases = json.RawMessage("[]")
return &m, nil
}
// scanMappingRead scans rows from GetByID / Search queries
// (includes category_name and aliases lateral join).
func scanMappingRead(row pgx.Row) (*IngredientMapping, error) {
var m IngredientMapping
var aliases []byte
err := row.Scan(
&m.ID, &m.CanonicalName, &m.Category, &m.CategoryName, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt, &aliases,
)
if err != nil {
return nil, err
}
m.Aliases = json.RawMessage(aliases)
return &m, nil
}
func collectMappingsWrite(rows pgx.Rows) ([]*IngredientMapping, error) {
var result []*IngredientMapping
for rows.Next() {
var m IngredientMapping
if err := rows.Scan(
&m.ID, &m.CanonicalName, &m.Category, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan mapping: %w", err)
}
m.Aliases = json.RawMessage("[]")
result = append(result, &m)
}
return result, rows.Err()
}
func collectMappingsRead(rows pgx.Rows) ([]*IngredientMapping, error) {
var result []*IngredientMapping
for rows.Next() {
var m IngredientMapping
var aliases []byte
if err := rows.Scan(
&m.ID, &m.CanonicalName, &m.Category, &m.CategoryName, &m.DefaultUnit,
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt, &aliases,
); err != nil {
return nil, fmt.Errorf("scan mapping: %w", err)
}
m.Aliases = json.RawMessage(aliases)
result = append(result, &m)
}
return result, rows.Err()
}

View File

@@ -268,15 +268,15 @@ func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart stri
return id, nil return id, nil
} }
// GetIngredientsByPlan returns all ingredients from all recipes in the plan. // GetIngredientsByPlan returns all products from all recipes in the plan.
func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) { func (r *Repository) GetIngredientsByPlan(ctx context.Context, planID string) ([]ingredientRow, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT ri.name, ri.amount, ri.unit_code, mi.meal_type SELECT rp.name, rp.amount, rp.unit_code, mi.meal_type
FROM menu_items mi FROM menu_items mi
JOIN recipes rec ON rec.id = mi.recipe_id JOIN recipes rec ON rec.id = mi.recipe_id
JOIN recipe_ingredients ri ON ri.recipe_id = rec.id JOIN recipe_products rp ON rp.recipe_id = rec.id
WHERE mi.menu_plan_id = $1 WHERE mi.menu_plan_id = $1
ORDER BY ri.sort_order`, planID) ORDER BY rp.sort_order`, planID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get ingredients by plan: %w", err) return nil, fmt.Errorf("get ingredients by plan: %w", err)
} }

View File

@@ -1,41 +1,29 @@
package product package product
import "time" import (
"encoding/json"
"time"
)
// Product is a user's food item in their pantry. // Product is a catalog entry representing a food ingredient or packaged product.
// CanonicalName holds the English name; localized names are resolved at query time.
type Product struct { type Product struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` CanonicalName string `json:"canonical_name"`
PrimaryIngredientID *string `json:"primary_ingredient_id"` Aliases json.RawMessage `json:"aliases"` // []string, populated by read queries
Name string `json:"name"` Category *string `json:"category"`
Quantity float64 `json:"quantity"` CategoryName *string `json:"category_name"` // localized category display name
Unit string `json:"unit"` DefaultUnit *string `json:"default_unit"`
Category *string `json:"category"` DefaultUnitName *string `json:"default_unit_name,omitempty"`
StorageDays int `json:"storage_days"` Barcode *string `json:"barcode,omitempty"`
AddedAt time.Time `json:"added_at"`
ExpiresAt time.Time `json:"expires_at"`
DaysLeft int `json:"days_left"`
ExpiringSoon bool `json:"expiring_soon"`
}
// CreateRequest is the body for POST /products. CaloriesPer100g *float64 `json:"calories_per_100g"`
type CreateRequest struct { ProteinPer100g *float64 `json:"protein_per_100g"`
PrimaryIngredientID *string `json:"primary_ingredient_id"` FatPer100g *float64 `json:"fat_per_100g"`
// Accept both "primary_ingredient_id" (new) and "mapping_id" (legacy client) fields. CarbsPer100g *float64 `json:"carbs_per_100g"`
MappingID *string `json:"mapping_id"` FiberPer100g *float64 `json:"fiber_per_100g"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
}
// UpdateRequest is the body for PUT /products/{id}. StorageDays *int `json:"storage_days"`
// All fields are optional (nil = keep existing value). CreatedAt time.Time `json:"created_at"`
type UpdateRequest struct { UpdatedAt time.Time `json:"updated_at"`
Name *string `json:"name"`
Quantity *float64 `json:"quantity"`
Unit *string `json:"unit"`
Category *string `json:"category"`
StorageDays *int `json:"storage_days"`
} }

View File

@@ -3,145 +3,121 @@ package product
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"github.com/food-ai/backend/internal/infra/middleware"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// ProductRepository is the data layer interface used by Handler. // ProductSearcher is the data layer interface used by Handler for search.
type ProductRepository interface { type ProductSearcher interface {
List(ctx context.Context, userID string) ([]*Product, error) Search(ctx context.Context, query string, limit int) ([]*Product, error)
Create(ctx context.Context, userID string, req CreateRequest) (*Product, error) GetByBarcode(ctx context.Context, barcode string) (*Product, error)
BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*Product, error) UpsertByBarcode(ctx context.Context, catalogProduct *Product) (*Product, error)
Update(ctx context.Context, id, userID string, req UpdateRequest) (*Product, error)
Delete(ctx context.Context, id, userID string) error
} }
// Handler handles /products HTTP requests. // OpenFoodFactsClient fetches product data from Open Food Facts.
type OpenFoodFactsClient interface {
Fetch(requestContext context.Context, barcode string) (*Product, error)
}
// Handler handles catalog product HTTP requests.
type Handler struct { type Handler struct {
repo ProductRepository repo ProductSearcher
openFoodFacts OpenFoodFactsClient
} }
// NewHandler creates a new Handler. // NewHandler creates a new Handler.
func NewHandler(repo ProductRepository) *Handler { func NewHandler(repo ProductSearcher, openFoodFacts OpenFoodFactsClient) *Handler {
return &Handler{repo: repo} return &Handler{repo: repo, openFoodFacts: openFoodFacts}
} }
// List handles GET /products. // Search handles GET /products/search?q=&limit=10.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { func (handler *Handler) Search(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(r.Context()) query := request.URL.Query().Get("q")
products, err := h.repo.List(r.Context(), userID) if query == "" {
if err != nil { responseWriter.Header().Set("Content-Type", "application/json")
slog.Error("list products", "user_id", userID, "err", err) _, _ = responseWriter.Write([]byte("[]"))
writeErrorJSON(w, http.StatusInternalServerError, "failed to list products")
return return
} }
limit := 10
if limitStr := request.URL.Query().Get("limit"); limitStr != "" {
if parsedLimit, parseError := strconv.Atoi(limitStr); parseError == nil && parsedLimit > 0 && parsedLimit <= 50 {
limit = parsedLimit
}
}
products, searchError := handler.repo.Search(request.Context(), query, limit)
if searchError != nil {
slog.Error("search catalog products", "q", query, "err", searchError)
responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(http.StatusInternalServerError)
_, _ = responseWriter.Write([]byte(`{"error":"search failed"}`))
return
}
if products == nil { if products == nil {
products = []*Product{} products = []*Product{}
} }
writeJSON(w, http.StatusOK, products)
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(products)
} }
// Create handles POST /products. // GetByBarcode handles GET /products/barcode/{barcode}.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { // Checks the database first; on miss, fetches from Open Food Facts and caches the result.
userID := middleware.UserIDFromCtx(r.Context()) func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request *http.Request) {
var req CreateRequest barcode := chi.URLParam(request, "barcode")
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if barcode == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body") writeErrorJSON(responseWriter, http.StatusBadRequest, "barcode is required")
return
}
if req.Name == "" {
writeErrorJSON(w, http.StatusBadRequest, "name is required")
return return
} }
p, err := h.repo.Create(r.Context(), userID, req) // Check the local catalog first.
if err != nil { catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode)
slog.Error("create product", "user_id", userID, "err", err) if lookupError != nil {
writeErrorJSON(w, http.StatusInternalServerError, "failed to create product") slog.Error("lookup product by barcode", "barcode", barcode, "err", lookupError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "lookup failed")
return return
} }
writeJSON(w, http.StatusCreated, p) if catalogProduct != nil {
} writeJSON(responseWriter, http.StatusOK, catalogProduct)
// BatchCreate handles POST /products/batch.
func (h *Handler) BatchCreate(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
var items []CreateRequest
if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
if len(items) == 0 {
writeJSON(w, http.StatusCreated, []*Product{})
return return
} }
products, err := h.repo.BatchCreate(r.Context(), userID, items) // Not in catalog — fetch from Open Food Facts.
if err != nil { fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode)
slog.Error("batch create products", "user_id", userID, "err", err) if fetchError != nil {
writeErrorJSON(w, http.StatusInternalServerError, "failed to create products") slog.Warn("open food facts fetch failed", "barcode", barcode, "err", fetchError)
return writeErrorJSON(responseWriter, http.StatusNotFound, "product not found")
}
writeJSON(w, http.StatusCreated, products)
}
// Update handles PUT /products/{id}.
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
id := chi.URLParam(r, "id")
var req UpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
return return
} }
p, err := h.repo.Update(r.Context(), id, userID, req) // Persist the fetched product so subsequent lookups are served from the DB.
if errors.Is(err, ErrNotFound) { savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct)
writeErrorJSON(w, http.StatusNotFound, "product not found") if upsertError != nil {
slog.Warn("upsert product from open food facts", "barcode", barcode, "err", upsertError)
// Return the fetched data even if we could not cache it.
writeJSON(responseWriter, http.StatusOK, fetchedProduct)
return return
} }
if err != nil { writeJSON(responseWriter, http.StatusOK, savedProduct)
slog.Error("update product", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to update product")
return
}
writeJSON(w, http.StatusOK, p)
}
// Delete handles DELETE /products/{id}.
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
id := chi.URLParam(r, "id")
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
if errors.Is(err, ErrNotFound) {
writeErrorJSON(w, http.StatusNotFound, "product not found")
return
}
slog.Error("delete product", "id", id, "err", err)
writeErrorJSON(w, http.StatusInternalServerError, "failed to delete product")
return
}
w.WriteHeader(http.StatusNoContent)
} }
type errorResponse struct { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
} }
func writeErrorJSON(w http.ResponseWriter, status int, msg string) { func writeErrorJSON(responseWriter http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) responseWriter.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) _ = json.NewEncoder(responseWriter).Encode(errorResponse{Error: msg})
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(responseWriter http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json") responseWriter.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) responseWriter.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(responseWriter).Encode(value)
} }

View File

@@ -1,36 +0,0 @@
package mocks
import (
"context"
"github.com/food-ai/backend/internal/domain/product"
)
// MockProductRepository is a test double implementing product.ProductRepository.
type MockProductRepository struct {
ListFn func(ctx context.Context, userID string) ([]*product.Product, error)
CreateFn func(ctx context.Context, userID string, req product.CreateRequest) (*product.Product, error)
BatchCreateFn func(ctx context.Context, userID string, items []product.CreateRequest) ([]*product.Product, error)
UpdateFn func(ctx context.Context, id, userID string, req product.UpdateRequest) (*product.Product, error)
DeleteFn func(ctx context.Context, id, userID string) error
}
func (m *MockProductRepository) List(ctx context.Context, userID string) ([]*product.Product, error) {
return m.ListFn(ctx, userID)
}
func (m *MockProductRepository) Create(ctx context.Context, userID string, req product.CreateRequest) (*product.Product, error) {
return m.CreateFn(ctx, userID, req)
}
func (m *MockProductRepository) BatchCreate(ctx context.Context, userID string, items []product.CreateRequest) ([]*product.Product, error) {
return m.BatchCreateFn(ctx, userID, items)
}
func (m *MockProductRepository) Update(ctx context.Context, id, userID string, req product.UpdateRequest) (*product.Product, error) {
return m.UpdateFn(ctx, id, userID, req)
}
func (m *MockProductRepository) Delete(ctx context.Context, id, userID string) error {
return m.DeleteFn(ctx, id, userID)
}

View File

@@ -0,0 +1,84 @@
package product
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// OpenFoodFacts is the client for the Open Food Facts public API.
type OpenFoodFacts struct {
httpClient *http.Client
}
// NewOpenFoodFacts creates a new OpenFoodFacts client with the default HTTP client.
func NewOpenFoodFacts() *OpenFoodFacts {
return &OpenFoodFacts{httpClient: &http.Client{}}
}
// offProduct is the JSON shape returned by the Open Food Facts v2 API.
type offProduct struct {
ProductName string `json:"product_name"`
Brands string `json:"brands"`
Nutriments offNutriments `json:"nutriments"`
}
type offNutriments struct {
EnergyKcal100g *float64 `json:"energy-kcal_100g"`
Proteins100g *float64 `json:"proteins_100g"`
Fat100g *float64 `json:"fat_100g"`
Carbohydrates100g *float64 `json:"carbohydrates_100g"`
Fiber100g *float64 `json:"fiber_100g"`
}
type offResponse struct {
Status int `json:"status"`
Product offProduct `json:"product"`
}
// Fetch retrieves a product from Open Food Facts by barcode.
// Returns an error if the product is not found or the API call fails.
func (client *OpenFoodFacts) Fetch(requestContext context.Context, barcode string) (*Product, error) {
url := fmt.Sprintf("https://world.openfoodfacts.org/api/v2/product/%s.json", barcode)
httpRequest, requestError := http.NewRequestWithContext(requestContext, http.MethodGet, url, nil)
if requestError != nil {
return nil, fmt.Errorf("build open food facts request: %w", requestError)
}
httpRequest.Header.Set("User-Agent", "FoodAI/1.0")
httpResponse, fetchError := client.httpClient.Do(httpRequest)
if fetchError != nil {
return nil, fmt.Errorf("open food facts request: %w", fetchError)
}
defer httpResponse.Body.Close()
if httpResponse.StatusCode != http.StatusOK {
return nil, fmt.Errorf("open food facts returned status %d for barcode %s", httpResponse.StatusCode, barcode)
}
var offResp offResponse
if decodeError := json.NewDecoder(httpResponse.Body).Decode(&offResp); decodeError != nil {
return nil, fmt.Errorf("decode open food facts response: %w", decodeError)
}
if offResp.Status == 0 {
return nil, fmt.Errorf("product %s not found in open food facts", barcode)
}
canonicalName := offResp.Product.ProductName
if canonicalName == "" {
canonicalName = barcode // Fall back to barcode as canonical name
}
barcodeValue := barcode
catalogProduct := &Product{
CanonicalName: canonicalName,
Barcode: &barcodeValue,
CaloriesPer100g: offResp.Product.Nutriments.EnergyKcal100g,
ProteinPer100g: offResp.Product.Nutriments.Proteins100g,
FatPer100g: offResp.Product.Nutriments.Fat100g,
CarbsPer100g: offResp.Product.Nutriments.Carbohydrates100g,
FiberPer100g: offResp.Product.Nutriments.Fiber100g,
}
return catalogProduct, nil
}

View File

@@ -2,18 +2,16 @@ package product
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/food-ai/backend/internal/infra/locale"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// ErrNotFound is returned when a product is not found or does not belong to the user. // Repository handles persistence for catalog products and their translations.
var ErrNotFound = errors.New("product not found")
// Repository handles product persistence.
type Repository struct { type Repository struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -23,180 +21,314 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool} return &Repository{pool: pool}
} }
// expires_at is computed in SQL because TIMESTAMPTZ + INTERVAL is STABLE (not IMMUTABLE), // Upsert inserts or updates a catalog product (English canonical content).
// which prevents it from being used as a stored generated column. // Conflict is resolved on canonical_name.
const selectCols = `id, user_id, primary_ingredient_id, name, quantity, unit, category, storage_days, added_at, func (r *Repository) Upsert(requestContext context.Context, catalogProduct *Product) (*Product, error) {
(added_at + storage_days * INTERVAL '1 day') AS expires_at` query := `
INSERT INTO products (
canonical_name,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (canonical_name) DO UPDATE SET
category = EXCLUDED.category,
default_unit = EXCLUDED.default_unit,
calories_per_100g = EXCLUDED.calories_per_100g,
protein_per_100g = EXCLUDED.protein_per_100g,
fat_per_100g = EXCLUDED.fat_per_100g,
carbs_per_100g = EXCLUDED.carbs_per_100g,
fiber_per_100g = EXCLUDED.fiber_per_100g,
storage_days = EXCLUDED.storage_days,
updated_at = now()
RETURNING id, canonical_name, category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at`
// List returns all products for a user, sorted by expires_at ASC. row := r.pool.QueryRow(requestContext, query,
func (r *Repository) List(ctx context.Context, userID string) ([]*Product, error) { catalogProduct.CanonicalName,
rows, err := r.pool.Query(ctx, ` catalogProduct.Category, catalogProduct.DefaultUnit,
SELECT `+selectCols+` catalogProduct.CaloriesPer100g, catalogProduct.ProteinPer100g, catalogProduct.FatPer100g, catalogProduct.CarbsPer100g, catalogProduct.FiberPer100g,
FROM products catalogProduct.StorageDays,
WHERE user_id = $1 )
ORDER BY expires_at ASC`, userID) return scanProductWrite(row)
if err != nil { }
return nil, fmt.Errorf("list products: %w", err)
// GetByID returns a catalog product by UUID.
// CanonicalName and aliases are resolved for the language stored in requestContext.
// Returns nil, nil if not found.
func (r *Repository) GetByID(requestContext context.Context, id string) (*Product, error) {
lang := locale.FromContext(requestContext)
query := `
SELECT p.id,
p.canonical_name,
p.category,
COALESCE(pct.name, pc.name) AS category_name,
p.default_unit,
COALESCE(ut.name, p.default_unit) AS unit_name,
p.calories_per_100g, p.protein_per_100g, p.fat_per_100g, p.carbs_per_100g, p.fiber_per_100g,
p.storage_days, p.created_at, p.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases
FROM products p
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 = $2
LEFT JOIN unit_translations ut
ON ut.unit_code = p.default_unit AND ut.lang = $2
LEFT JOIN LATERAL (
SELECT json_agg(pa.alias ORDER BY pa.alias) AS aliases
FROM product_aliases pa
WHERE pa.product_id = p.id AND pa.lang = $2
) al ON true
WHERE p.id = $1`
row := r.pool.QueryRow(requestContext, query, id, lang)
catalogProduct, queryError := scanProductRead(row)
if errors.Is(queryError, pgx.ErrNoRows) {
return nil, nil
}
return catalogProduct, queryError
}
// GetByBarcode returns a catalog product by barcode value.
// Returns nil, nil if not found.
func (r *Repository) GetByBarcode(requestContext context.Context, barcode string) (*Product, error) {
lang := locale.FromContext(requestContext)
query := `
SELECT p.id,
p.canonical_name,
p.category,
COALESCE(pct.name, pc.name) AS category_name,
p.default_unit,
COALESCE(ut.name, p.default_unit) AS unit_name,
p.calories_per_100g, p.protein_per_100g, p.fat_per_100g, p.carbs_per_100g, p.fiber_per_100g,
p.storage_days, p.created_at, p.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases
FROM products p
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 = $2
LEFT JOIN unit_translations ut
ON ut.unit_code = p.default_unit AND ut.lang = $2
LEFT JOIN LATERAL (
SELECT json_agg(pa.alias ORDER BY pa.alias) AS aliases
FROM product_aliases pa
WHERE pa.product_id = p.id AND pa.lang = $2
) al ON true
WHERE p.barcode = $1`
row := r.pool.QueryRow(requestContext, query, barcode, lang)
catalogProduct, queryError := scanProductRead(row)
if errors.Is(queryError, pgx.ErrNoRows) {
return nil, nil
}
return catalogProduct, queryError
}
// UpsertByBarcode inserts or updates a catalog product including its barcode.
func (r *Repository) UpsertByBarcode(requestContext context.Context, catalogProduct *Product) (*Product, error) {
query := `
INSERT INTO products (
canonical_name, barcode,
category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (canonical_name) DO UPDATE SET
barcode = EXCLUDED.barcode,
category = EXCLUDED.category,
default_unit = EXCLUDED.default_unit,
calories_per_100g = EXCLUDED.calories_per_100g,
protein_per_100g = EXCLUDED.protein_per_100g,
fat_per_100g = EXCLUDED.fat_per_100g,
carbs_per_100g = EXCLUDED.carbs_per_100g,
fiber_per_100g = EXCLUDED.fiber_per_100g,
storage_days = EXCLUDED.storage_days,
updated_at = now()
RETURNING id, canonical_name, category, default_unit,
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
storage_days, created_at, updated_at`
row := r.pool.QueryRow(requestContext, query,
catalogProduct.CanonicalName, catalogProduct.Barcode,
catalogProduct.Category, catalogProduct.DefaultUnit,
catalogProduct.CaloriesPer100g, catalogProduct.ProteinPer100g, catalogProduct.FatPer100g, catalogProduct.CarbsPer100g, catalogProduct.FiberPer100g,
catalogProduct.StorageDays,
)
return scanProductWrite(row)
}
// FuzzyMatch finds the single best matching catalog product for a given name.
// Returns nil, nil when no match is found.
func (r *Repository) FuzzyMatch(requestContext context.Context, name string) (*Product, error) {
results, searchError := r.Search(requestContext, name, 1)
if searchError != nil {
return nil, searchError
}
if len(results) == 0 {
return nil, nil
}
return results[0], nil
}
// Search finds catalog products matching the query string.
// Searches aliases table and translated names for the language in requestContext.
func (r *Repository) Search(requestContext context.Context, query string, limit int) ([]*Product, error) {
if limit <= 0 {
limit = 10
}
lang := locale.FromContext(requestContext)
searchQuery := `
SELECT p.id,
p.canonical_name,
p.category,
COALESCE(pct.name, pc.name) AS category_name,
p.default_unit,
COALESCE(ut.name, p.default_unit) AS unit_name,
p.calories_per_100g, p.protein_per_100g, p.fat_per_100g, p.carbs_per_100g, p.fiber_per_100g,
p.storage_days, p.created_at, p.updated_at,
COALESCE(al.aliases, '[]'::json) AS aliases
FROM products p
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
LEFT JOIN unit_translations ut
ON ut.unit_code = p.default_unit AND ut.lang = $3
LEFT JOIN LATERAL (
SELECT json_agg(pa.alias ORDER BY pa.alias) AS aliases
FROM product_aliases pa
WHERE pa.product_id = p.id AND pa.lang = $3
) al ON true
WHERE EXISTS (
SELECT 1 FROM product_aliases pa
WHERE pa.product_id = p.id
AND (pa.lang = $3 OR pa.lang = 'en')
AND pa.alias ILIKE '%' || $1 || '%'
)
OR p.canonical_name ILIKE '%' || $1 || '%'
OR similarity(p.canonical_name, $1) > 0.3
ORDER BY similarity(p.canonical_name, $1) DESC
LIMIT $2`
rows, queryError := r.pool.Query(requestContext, searchQuery, query, limit, lang)
if queryError != nil {
return nil, fmt.Errorf("search products: %w", queryError)
} }
defer rows.Close() defer rows.Close()
return collectProducts(rows) return collectProductsRead(rows)
} }
// Create inserts a new product and returns the created record. // Count returns the total number of catalog products.
func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Product, error) { func (r *Repository) Count(requestContext context.Context) (int, error) {
storageDays := req.StorageDays var count int
if storageDays <= 0 { if queryError := r.pool.QueryRow(requestContext, `SELECT count(*) FROM products`).Scan(&count); queryError != nil {
storageDays = 7 return 0, fmt.Errorf("count products: %w", queryError)
} }
unit := req.Unit return count, nil
if unit == "" {
unit = "pcs"
}
qty := req.Quantity
if qty <= 0 {
qty = 1
}
// Accept both new and legacy field names.
primaryID := req.PrimaryIngredientID
if primaryID == nil {
primaryID = req.MappingID
}
row := r.pool.QueryRow(ctx, `
INSERT INTO products (user_id, primary_ingredient_id, name, quantity, unit, category, storage_days)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+selectCols,
userID, primaryID, req.Name, qty, unit, req.Category, storageDays,
)
return scanProduct(row)
} }
// BatchCreate inserts multiple products sequentially and returns all created records. // UpsertAliases inserts aliases for a given catalog product and language.
func (r *Repository) BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*Product, error) { func (r *Repository) UpsertAliases(requestContext context.Context, id, lang string, aliases []string) error {
var result []*Product if len(aliases) == 0 {
for _, req := range items { return nil
p, err := r.Create(ctx, userID, req) }
if err != nil { batch := &pgx.Batch{}
return nil, fmt.Errorf("batch create product %q: %w", req.Name, err) for _, alias := range aliases {
batch.Queue(
`INSERT INTO product_aliases (product_id, lang, alias) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
id, lang, alias,
)
}
results := r.pool.SendBatch(requestContext, batch)
defer results.Close()
for range aliases {
if _, execError := results.Exec(); execError != nil {
return fmt.Errorf("upsert product alias %s/%s: %w", id, lang, execError)
} }
result = append(result, p)
}
return result, nil
}
// Update modifies an existing product. Only non-nil fields are changed.
// Returns ErrNotFound if the product does not exist or belongs to a different user.
func (r *Repository) Update(ctx context.Context, id, userID string, req UpdateRequest) (*Product, error) {
row := r.pool.QueryRow(ctx, `
UPDATE products SET
name = COALESCE($3, name),
quantity = COALESCE($4, quantity),
unit = COALESCE($5, unit),
category = COALESCE($6, category),
storage_days = COALESCE($7, storage_days)
WHERE id = $1 AND user_id = $2
RETURNING `+selectCols,
id, userID, req.Name, req.Quantity, req.Unit, req.Category, req.StorageDays,
)
p, err := scanProduct(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return p, err
}
// Delete removes a product. Returns ErrNotFound if it does not exist or belongs to a different user.
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM products WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete product: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
} }
return nil return nil
} }
// ListForPrompt returns a human-readable list of user's products for the AI prompt. // UpsertCategoryTranslation inserts or replaces a localized category name.
// Expiring soon items are marked with ⚠. func (r *Repository) UpsertCategoryTranslation(requestContext context.Context, slug, lang, name string) error {
func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string, error) { query := `
rows, err := r.pool.Query(ctx, ` INSERT INTO product_category_translations (product_category_slug, lang, name)
WITH p AS ( VALUES ($1, $2, $3)
SELECT name, quantity, unit, ON CONFLICT (product_category_slug, lang) DO UPDATE SET name = EXCLUDED.name`
(added_at + storage_days * INTERVAL '1 day') AS expires_at
FROM products
WHERE user_id = $1
)
SELECT name, quantity, unit, expires_at
FROM p
ORDER BY expires_at ASC`, userID)
if err != nil {
return nil, fmt.Errorf("list products for prompt: %w", err)
}
defer rows.Close()
var lines []string if _, execError := r.pool.Exec(requestContext, query, slug, lang, name); execError != nil {
now := time.Now() return fmt.Errorf("upsert category translation %s/%s: %w", slug, lang, execError)
for rows.Next() {
var name, unit string
var qty float64
var expiresAt time.Time
if err := rows.Scan(&name, &qty, &unit, &expiresAt); err != nil {
return nil, fmt.Errorf("scan product for prompt: %w", err)
}
daysLeft := int(expiresAt.Sub(now).Hours() / 24)
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
switch {
case daysLeft <= 0:
line += " (expires today ⚠)"
case daysLeft == 1:
line += " (expires tomorrow ⚠)"
case daysLeft <= 3:
line += fmt.Sprintf(" (expires in %d days ⚠)", daysLeft)
}
lines = append(lines, line)
} }
return lines, rows.Err() return nil
} }
// --- helpers --- // --- scan helpers ---
func scanProduct(row pgx.Row) (*Product, error) { func scanProductWrite(row pgx.Row) (*Product, error) {
var p Product var catalogProduct Product
err := row.Scan( scanError := row.Scan(
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit, &catalogProduct.ID, &catalogProduct.CanonicalName, &catalogProduct.Category, &catalogProduct.DefaultUnit,
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt, &catalogProduct.CaloriesPer100g, &catalogProduct.ProteinPer100g, &catalogProduct.FatPer100g, &catalogProduct.CarbsPer100g, &catalogProduct.FiberPer100g,
&catalogProduct.StorageDays, &catalogProduct.CreatedAt, &catalogProduct.UpdatedAt,
) )
if err != nil { if scanError != nil {
return nil, err return nil, scanError
} }
computeDaysLeft(&p) catalogProduct.Aliases = json.RawMessage("[]")
return &p, nil return &catalogProduct, nil
} }
func collectProducts(rows pgx.Rows) ([]*Product, error) { func scanProductRead(row pgx.Row) (*Product, error) {
var catalogProduct Product
var aliases []byte
scanError := row.Scan(
&catalogProduct.ID, &catalogProduct.CanonicalName, &catalogProduct.Category, &catalogProduct.CategoryName,
&catalogProduct.DefaultUnit, &catalogProduct.DefaultUnitName,
&catalogProduct.CaloriesPer100g, &catalogProduct.ProteinPer100g, &catalogProduct.FatPer100g, &catalogProduct.CarbsPer100g, &catalogProduct.FiberPer100g,
&catalogProduct.StorageDays, &catalogProduct.CreatedAt, &catalogProduct.UpdatedAt, &aliases,
)
if scanError != nil {
return nil, scanError
}
catalogProduct.Aliases = json.RawMessage(aliases)
return &catalogProduct, nil
}
func collectProductsWrite(rows pgx.Rows) ([]*Product, error) {
var result []*Product var result []*Product
for rows.Next() { for rows.Next() {
var p Product var catalogProduct Product
if err := rows.Scan( if scanError := rows.Scan(
&p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit, &catalogProduct.ID, &catalogProduct.CanonicalName, &catalogProduct.Category, &catalogProduct.DefaultUnit,
&p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt, &catalogProduct.CaloriesPer100g, &catalogProduct.ProteinPer100g, &catalogProduct.FatPer100g, &catalogProduct.CarbsPer100g, &catalogProduct.FiberPer100g,
); err != nil { &catalogProduct.StorageDays, &catalogProduct.CreatedAt, &catalogProduct.UpdatedAt,
return nil, fmt.Errorf("scan product: %w", err) ); scanError != nil {
return nil, fmt.Errorf("scan product: %w", scanError)
} }
computeDaysLeft(&p) catalogProduct.Aliases = json.RawMessage("[]")
result = append(result, &p) result = append(result, &catalogProduct)
} }
return result, rows.Err() return result, rows.Err()
} }
func computeDaysLeft(p *Product) { func collectProductsRead(rows pgx.Rows) ([]*Product, error) {
d := int(time.Until(p.ExpiresAt).Hours() / 24) var result []*Product
if d < 0 { for rows.Next() {
d = 0 var catalogProduct Product
var aliases []byte
if scanError := rows.Scan(
&catalogProduct.ID, &catalogProduct.CanonicalName, &catalogProduct.Category, &catalogProduct.CategoryName,
&catalogProduct.DefaultUnit, &catalogProduct.DefaultUnitName,
&catalogProduct.CaloriesPer100g, &catalogProduct.ProteinPer100g, &catalogProduct.FatPer100g, &catalogProduct.CarbsPer100g, &catalogProduct.FiberPer100g,
&catalogProduct.StorageDays, &catalogProduct.CreatedAt, &catalogProduct.UpdatedAt, &aliases,
); scanError != nil {
return nil, fmt.Errorf("scan product: %w", scanError)
}
catalogProduct.Aliases = json.RawMessage(aliases)
result = append(result, &catalogProduct)
} }
p.DaysLeft = d return result, rows.Err()
p.ExpiringSoon = d <= 3
} }

View File

@@ -12,7 +12,7 @@ import (
"github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/adapters/ai"
"github.com/food-ai/backend/internal/domain/dish" "github.com/food-ai/backend/internal/domain/dish"
"github.com/food-ai/backend/internal/domain/ingredient" "github.com/food-ai/backend/internal/domain/product"
"github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/locale"
"github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/infra/middleware"
) )
@@ -26,12 +26,10 @@ type DishRepository interface {
AddRecipe(ctx context.Context, dishID string, req dish.CreateRequest) (string, error) AddRecipe(ctx context.Context, dishID string, req dish.CreateRequest) (string, error)
} }
// IngredientRepository is the subset of ingredient.Repository used by this handler. // ProductRepository is the subset of product.Repository used by this handler.
type IngredientRepository interface { type ProductRepository interface {
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error) FuzzyMatch(ctx context.Context, name string) (*product.Product, error)
Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error) Upsert(ctx context.Context, catalogProduct *product.Product) (*product.Product, error)
UpsertTranslation(ctx context.Context, id, lang, name string) error
UpsertAliases(ctx context.Context, id, lang string, aliases []string) error
} }
// Recognizer is the AI provider interface for image-based food recognition. // Recognizer is the AI provider interface for image-based food recognition.
@@ -51,27 +49,27 @@ type KafkaPublisher interface {
// Handler handles POST /ai/* recognition endpoints. // Handler handles POST /ai/* recognition endpoints.
type Handler struct { type Handler struct {
recognizer Recognizer recognizer Recognizer
ingredientRepo IngredientRepository productRepo ProductRepository
jobRepo JobRepository jobRepo JobRepository
kafkaProducer KafkaPublisher kafkaProducer KafkaPublisher
sseBroker *SSEBroker sseBroker *SSEBroker
} }
// NewHandler creates a new Handler with async dish recognition support. // NewHandler creates a new Handler with async dish recognition support.
func NewHandler( func NewHandler(
recognizer Recognizer, recognizer Recognizer,
ingredientRepo IngredientRepository, productRepo ProductRepository,
jobRepo JobRepository, jobRepo JobRepository,
kafkaProducer KafkaPublisher, kafkaProducer KafkaPublisher,
sseBroker *SSEBroker, sseBroker *SSEBroker,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
recognizer: recognizer, recognizer: recognizer,
ingredientRepo: ingredientRepo, productRepo: productRepo,
jobRepo: jobRepo, jobRepo: jobRepo,
kafkaProducer: kafkaProducer, kafkaProducer: kafkaProducer,
sseBroker: sseBroker, sseBroker: sseBroker,
} }
} }
@@ -293,7 +291,7 @@ func (handler *Handler) GetJob(responseWriter http.ResponseWriter, request *http
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// enrichItems matches each recognized item against ingredient_mappings. // enrichItems matches each recognized item against the product catalog.
// Items without a match trigger a classification call and upsert into the DB. // Items without a match trigger a classification call and upsert into the DB.
func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []EnrichedItem { func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []EnrichedItem {
result := make([]EnrichedItem, 0, len(items)) result := make([]EnrichedItem, 0, len(items))
@@ -307,27 +305,27 @@ func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedIt
StorageDays: 7, // sensible default StorageDays: 7, // sensible default
} }
mapping, matchError := handler.ingredientRepo.FuzzyMatch(ctx, item.Name) catalogProduct, matchError := handler.productRepo.FuzzyMatch(ctx, item.Name)
if matchError != nil { if matchError != nil {
slog.Warn("fuzzy match ingredient", "name", item.Name, "err", matchError) slog.Warn("fuzzy match product", "name", item.Name, "err", matchError)
} }
if mapping != nil { if catalogProduct != nil {
id := mapping.ID id := catalogProduct.ID
enriched.MappingID = &id enriched.MappingID = &id
if mapping.DefaultUnit != nil { if catalogProduct.DefaultUnit != nil {
enriched.Unit = *mapping.DefaultUnit enriched.Unit = *catalogProduct.DefaultUnit
} }
if mapping.StorageDays != nil { if catalogProduct.StorageDays != nil {
enriched.StorageDays = *mapping.StorageDays enriched.StorageDays = *catalogProduct.StorageDays
} }
if mapping.Category != nil { if catalogProduct.Category != nil {
enriched.Category = *mapping.Category enriched.Category = *catalogProduct.Category
} }
} else { } else {
classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name) classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name)
if classifyError != nil { if classifyError != nil {
slog.Warn("classify unknown ingredient", "name", item.Name, "err", classifyError) slog.Warn("classify unknown product", "name", item.Name, "err", classifyError)
} else { } else {
saved := handler.saveClassification(ctx, classification) saved := handler.saveClassification(ctx, classification)
if saved != nil { if saved != nil {
@@ -344,13 +342,13 @@ func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedIt
return result return result
} }
// saveClassification upserts an AI-produced ingredient classification into the DB. // saveClassification upserts an AI-produced classification into the product catalog.
func (handler *Handler) saveClassification(ctx context.Context, classification *ai.IngredientClassification) *ingredient.IngredientMapping { func (handler *Handler) saveClassification(ctx context.Context, classification *ai.IngredientClassification) *product.Product {
if classification == nil || classification.CanonicalName == "" { if classification == nil || classification.CanonicalName == "" {
return nil return nil
} }
mapping := &ingredient.IngredientMapping{ catalogProduct := &product.Product{
CanonicalName: classification.CanonicalName, CanonicalName: classification.CanonicalName,
Category: strPtr(classification.Category), Category: strPtr(classification.Category),
DefaultUnit: strPtr(classification.DefaultUnit), DefaultUnit: strPtr(classification.DefaultUnit),
@@ -361,29 +359,12 @@ func (handler *Handler) saveClassification(ctx context.Context, classification *
StorageDays: intPtr(classification.StorageDays), StorageDays: intPtr(classification.StorageDays),
} }
saved, upsertError := handler.ingredientRepo.Upsert(ctx, mapping) saved, upsertError := handler.productRepo.Upsert(ctx, catalogProduct)
if upsertError != nil { if upsertError != nil {
slog.Warn("upsert classified ingredient", "name", classification.CanonicalName, "err", upsertError) slog.Warn("upsert classified product", "name", classification.CanonicalName, "err", upsertError)
return nil return nil
} }
if len(classification.Aliases) > 0 {
if aliasError := handler.ingredientRepo.UpsertAliases(ctx, saved.ID, "en", classification.Aliases); aliasError != nil {
slog.Warn("upsert ingredient aliases", "id", saved.ID, "err", aliasError)
}
}
for _, translation := range classification.Translations {
if translationError := handler.ingredientRepo.UpsertTranslation(ctx, saved.ID, translation.Lang, translation.Name); translationError != nil {
slog.Warn("upsert ingredient translation", "id", saved.ID, "lang", translation.Lang, "err", translationError)
}
if len(translation.Aliases) > 0 {
if aliasError := handler.ingredientRepo.UpsertAliases(ctx, saved.ID, translation.Lang, translation.Aliases); aliasError != nil {
slog.Warn("upsert ingredient translation aliases", "id", saved.ID, "lang", translation.Lang, "err", aliasError)
}
}
}
return saved return saved
} }

View File

@@ -0,0 +1,41 @@
package userproduct
import "time"
// UserProduct is a user's food item in their pantry.
type UserProduct struct {
ID string `json:"id"`
UserID string `json:"user_id"`
PrimaryProductID *string `json:"primary_product_id"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
AddedAt time.Time `json:"added_at"`
ExpiresAt time.Time `json:"expires_at"`
DaysLeft int `json:"days_left"`
ExpiringSoon bool `json:"expiring_soon"`
}
// CreateRequest is the body for POST /user-products.
type CreateRequest struct {
PrimaryProductID *string `json:"primary_product_id"`
// Accept "mapping_id" as a legacy alias for backward compatibility.
MappingID *string `json:"mapping_id"`
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category *string `json:"category"`
StorageDays int `json:"storage_days"`
}
// UpdateRequest is the body for PUT /user-products/{id}.
// All fields are optional (nil = keep existing value).
type UpdateRequest struct {
Name *string `json:"name"`
Quantity *float64 `json:"quantity"`
Unit *string `json:"unit"`
Category *string `json:"category"`
StorageDays *int `json:"storage_days"`
}

View File

@@ -0,0 +1,147 @@
package userproduct
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/food-ai/backend/internal/infra/middleware"
"github.com/go-chi/chi/v5"
)
// UserProductRepository is the data layer interface used by Handler.
type UserProductRepository interface {
List(ctx context.Context, userID string) ([]*UserProduct, error)
Create(ctx context.Context, userID string, req CreateRequest) (*UserProduct, error)
BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*UserProduct, error)
Update(ctx context.Context, id, userID string, req UpdateRequest) (*UserProduct, error)
Delete(ctx context.Context, id, userID string) error
}
// Handler handles /user-products HTTP requests.
type Handler struct {
repo UserProductRepository
}
// NewHandler creates a new Handler.
func NewHandler(repo UserProductRepository) *Handler {
return &Handler{repo: repo}
}
// List handles GET /user-products.
func (handler *Handler) List(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
userProducts, listError := handler.repo.List(request.Context(), userID)
if listError != nil {
slog.Error("list user products", "user_id", userID, "err", listError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list user products")
return
}
if userProducts == nil {
userProducts = []*UserProduct{}
}
writeJSON(responseWriter, http.StatusOK, userProducts)
}
// Create handles POST /user-products.
func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
var req CreateRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeErrorJSON(responseWriter, http.StatusBadRequest, "name is required")
return
}
userProduct, createError := handler.repo.Create(request.Context(), userID, req)
if createError != nil {
slog.Error("create user product", "user_id", userID, "err", createError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user product")
return
}
writeJSON(responseWriter, http.StatusCreated, userProduct)
}
// BatchCreate handles POST /user-products/batch.
func (handler *Handler) BatchCreate(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
var items []CreateRequest
if decodeError := json.NewDecoder(request.Body).Decode(&items); decodeError != nil {
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body")
return
}
if len(items) == 0 {
writeJSON(responseWriter, http.StatusCreated, []*UserProduct{})
return
}
userProducts, batchError := handler.repo.BatchCreate(request.Context(), userID, items)
if batchError != nil {
slog.Error("batch create user products", "user_id", userID, "err", batchError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create user products")
return
}
writeJSON(responseWriter, http.StatusCreated, userProducts)
}
// Update handles PUT /user-products/{id}.
func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
id := chi.URLParam(request, "id")
var req UpdateRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil {
writeErrorJSON(responseWriter, http.StatusBadRequest, "invalid request body")
return
}
userProduct, updateError := handler.repo.Update(request.Context(), id, userID, req)
if errors.Is(updateError, ErrNotFound) {
writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found")
return
}
if updateError != nil {
slog.Error("update user product", "id", id, "err", updateError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to update user product")
return
}
writeJSON(responseWriter, http.StatusOK, userProduct)
}
// Delete handles DELETE /user-products/{id}.
func (handler *Handler) Delete(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
id := chi.URLParam(request, "id")
if deleteError := handler.repo.Delete(request.Context(), id, userID); deleteError != nil {
if errors.Is(deleteError, ErrNotFound) {
writeErrorJSON(responseWriter, http.StatusNotFound, "user product not found")
return
}
slog.Error("delete user product", "id", id, "err", deleteError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to delete user product")
return
}
responseWriter.WriteHeader(http.StatusNoContent)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeErrorJSON(responseWriter http.ResponseWriter, status int, msg string) {
responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(errorResponse{Error: msg})
}
func writeJSON(responseWriter http.ResponseWriter, status int, value any) {
responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(value)
}

View File

@@ -0,0 +1,36 @@
package mocks
import (
"context"
"github.com/food-ai/backend/internal/domain/userproduct"
)
// MockUserProductRepository is a test double implementing userproduct.UserProductRepository.
type MockUserProductRepository struct {
ListFn func(ctx context.Context, userID string) ([]*userproduct.UserProduct, error)
CreateFn func(ctx context.Context, userID string, req userproduct.CreateRequest) (*userproduct.UserProduct, error)
BatchCreateFn func(ctx context.Context, userID string, items []userproduct.CreateRequest) ([]*userproduct.UserProduct, error)
UpdateFn func(ctx context.Context, id, userID string, req userproduct.UpdateRequest) (*userproduct.UserProduct, error)
DeleteFn func(ctx context.Context, id, userID string) error
}
func (m *MockUserProductRepository) List(ctx context.Context, userID string) ([]*userproduct.UserProduct, error) {
return m.ListFn(ctx, userID)
}
func (m *MockUserProductRepository) Create(ctx context.Context, userID string, req userproduct.CreateRequest) (*userproduct.UserProduct, error) {
return m.CreateFn(ctx, userID, req)
}
func (m *MockUserProductRepository) BatchCreate(ctx context.Context, userID string, items []userproduct.CreateRequest) ([]*userproduct.UserProduct, error) {
return m.BatchCreateFn(ctx, userID, items)
}
func (m *MockUserProductRepository) Update(ctx context.Context, id, userID string, req userproduct.UpdateRequest) (*userproduct.UserProduct, error) {
return m.UpdateFn(ctx, id, userID, req)
}
func (m *MockUserProductRepository) Delete(ctx context.Context, id, userID string) error {
return m.DeleteFn(ctx, id, userID)
}

View File

@@ -0,0 +1,202 @@
package userproduct
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a user product is not found or does not belong to the user.
var ErrNotFound = errors.New("user product not found")
// Repository handles user product persistence.
type Repository struct {
pool *pgxpool.Pool
}
// NewRepository creates a new Repository.
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// expires_at is computed in SQL because TIMESTAMPTZ + INTERVAL is STABLE (not IMMUTABLE),
// which prevents it from being used as a stored generated column.
const selectCols = `id, user_id, primary_product_id, name, quantity, unit, category, storage_days, added_at,
(added_at + storage_days * INTERVAL '1 day') AS expires_at`
// List returns all user products sorted by expires_at ASC.
func (r *Repository) List(requestContext context.Context, userID string) ([]*UserProduct, error) {
rows, queryError := r.pool.Query(requestContext, `
SELECT `+selectCols+`
FROM user_products
WHERE user_id = $1
ORDER BY expires_at ASC`, userID)
if queryError != nil {
return nil, fmt.Errorf("list user products: %w", queryError)
}
defer rows.Close()
return collectUserProducts(rows)
}
// Create inserts a new user product and returns the created record.
func (r *Repository) Create(requestContext context.Context, userID string, req CreateRequest) (*UserProduct, error) {
storageDays := req.StorageDays
if storageDays <= 0 {
storageDays = 7
}
unit := req.Unit
if unit == "" {
unit = "pcs"
}
qty := req.Quantity
if qty <= 0 {
qty = 1
}
// Accept both new and legacy field names.
primaryID := req.PrimaryProductID
if primaryID == nil {
primaryID = req.MappingID
}
row := r.pool.QueryRow(requestContext, `
INSERT INTO user_products (user_id, primary_product_id, name, quantity, unit, category, storage_days)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+selectCols,
userID, primaryID, req.Name, qty, unit, req.Category, storageDays,
)
return scanUserProduct(row)
}
// BatchCreate inserts multiple user products sequentially and returns all created records.
func (r *Repository) BatchCreate(requestContext context.Context, userID string, items []CreateRequest) ([]*UserProduct, error) {
var result []*UserProduct
for _, req := range items {
userProduct, createError := r.Create(requestContext, userID, req)
if createError != nil {
return nil, fmt.Errorf("batch create user product %q: %w", req.Name, createError)
}
result = append(result, userProduct)
}
return result, nil
}
// Update modifies an existing user product. Only non-nil fields are changed.
// Returns ErrNotFound if the product does not exist or belongs to a different user.
func (r *Repository) Update(requestContext context.Context, id, userID string, req UpdateRequest) (*UserProduct, error) {
row := r.pool.QueryRow(requestContext, `
UPDATE user_products SET
name = COALESCE($3, name),
quantity = COALESCE($4, quantity),
unit = COALESCE($5, unit),
category = COALESCE($6, category),
storage_days = COALESCE($7, storage_days)
WHERE id = $1 AND user_id = $2
RETURNING `+selectCols,
id, userID, req.Name, req.Quantity, req.Unit, req.Category, req.StorageDays,
)
userProduct, scanError := scanUserProduct(row)
if errors.Is(scanError, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return userProduct, scanError
}
// Delete removes a user product. Returns ErrNotFound if it does not exist or belongs to a different user.
func (r *Repository) Delete(requestContext context.Context, id, userID string) error {
tag, execError := r.pool.Exec(requestContext,
`DELETE FROM user_products WHERE id = $1 AND user_id = $2`, id, userID)
if execError != nil {
return fmt.Errorf("delete user product: %w", execError)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListForPrompt returns a human-readable list of user's products for the AI prompt.
// Expiring soon items are marked with ⚠.
func (r *Repository) ListForPrompt(requestContext context.Context, userID string) ([]string, error) {
rows, queryError := r.pool.Query(requestContext, `
WITH up AS (
SELECT name, quantity, unit,
(added_at + storage_days * INTERVAL '1 day') AS expires_at
FROM user_products
WHERE user_id = $1
)
SELECT name, quantity, unit, expires_at
FROM up
ORDER BY expires_at ASC`, userID)
if queryError != nil {
return nil, fmt.Errorf("list user products for prompt: %w", queryError)
}
defer rows.Close()
var lines []string
now := time.Now()
for rows.Next() {
var name, unit string
var qty float64
var expiresAt time.Time
if scanError := rows.Scan(&name, &qty, &unit, &expiresAt); scanError != nil {
return nil, fmt.Errorf("scan user product for prompt: %w", scanError)
}
daysLeft := int(expiresAt.Sub(now).Hours() / 24)
line := fmt.Sprintf("- %s %.0f %s", name, qty, unit)
switch {
case daysLeft <= 0:
line += " (expires today ⚠)"
case daysLeft == 1:
line += " (expires tomorrow ⚠)"
case daysLeft <= 3:
line += fmt.Sprintf(" (expires in %d days ⚠)", daysLeft)
}
lines = append(lines, line)
}
return lines, rows.Err()
}
// --- helpers ---
func scanUserProduct(row pgx.Row) (*UserProduct, error) {
var userProduct UserProduct
scanError := row.Scan(
&userProduct.ID, &userProduct.UserID, &userProduct.PrimaryProductID, &userProduct.Name, &userProduct.Quantity, &userProduct.Unit,
&userProduct.Category, &userProduct.StorageDays, &userProduct.AddedAt, &userProduct.ExpiresAt,
)
if scanError != nil {
return nil, scanError
}
computeDaysLeft(&userProduct)
return &userProduct, nil
}
func collectUserProducts(rows pgx.Rows) ([]*UserProduct, error) {
var result []*UserProduct
for rows.Next() {
var userProduct UserProduct
if scanError := rows.Scan(
&userProduct.ID, &userProduct.UserID, &userProduct.PrimaryProductID, &userProduct.Name, &userProduct.Quantity, &userProduct.Unit,
&userProduct.Category, &userProduct.StorageDays, &userProduct.AddedAt, &userProduct.ExpiresAt,
); scanError != nil {
return nil, fmt.Errorf("scan user product: %w", scanError)
}
computeDaysLeft(&userProduct)
result = append(result, &userProduct)
}
return result, rows.Err()
}
func computeDaysLeft(userProduct *UserProduct) {
days := int(time.Until(userProduct.ExpiresAt).Hours() / 24)
if days < 0 {
days = 0
}
userProduct.DaysLeft = days
userProduct.ExpiringSoon = days <= 3
}

View File

@@ -8,16 +8,16 @@ import (
"github.com/food-ai/backend/internal/domain/diary" "github.com/food-ai/backend/internal/domain/diary"
"github.com/food-ai/backend/internal/domain/dish" "github.com/food-ai/backend/internal/domain/dish"
"github.com/food-ai/backend/internal/domain/home" "github.com/food-ai/backend/internal/domain/home"
"github.com/food-ai/backend/internal/domain/ingredient"
"github.com/food-ai/backend/internal/domain/language" "github.com/food-ai/backend/internal/domain/language"
"github.com/food-ai/backend/internal/domain/menu" "github.com/food-ai/backend/internal/domain/menu"
"github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/infra/middleware"
"github.com/food-ai/backend/internal/domain/recipe"
"github.com/food-ai/backend/internal/domain/product" "github.com/food-ai/backend/internal/domain/product"
"github.com/food-ai/backend/internal/domain/recipe"
"github.com/food-ai/backend/internal/domain/recognition" "github.com/food-ai/backend/internal/domain/recognition"
"github.com/food-ai/backend/internal/domain/recommendation" "github.com/food-ai/backend/internal/domain/recommendation"
"github.com/food-ai/backend/internal/domain/savedrecipe" "github.com/food-ai/backend/internal/domain/savedrecipe"
"github.com/food-ai/backend/internal/domain/user" "github.com/food-ai/backend/internal/domain/user"
"github.com/food-ai/backend/internal/domain/userproduct"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
@@ -28,8 +28,8 @@ func NewRouter(
userHandler *user.Handler, userHandler *user.Handler,
recommendationHandler *recommendation.Handler, recommendationHandler *recommendation.Handler,
savedRecipeHandler *savedrecipe.Handler, savedRecipeHandler *savedrecipe.Handler,
ingredientHandler *ingredient.Handler,
productHandler *product.Handler, productHandler *product.Handler,
userProductHandler *userproduct.Handler,
recognitionHandler *recognition.Handler, recognitionHandler *recognition.Handler,
menuHandler *menu.Handler, menuHandler *menu.Handler,
diaryHandler *diary.Handler, diaryHandler *diary.Handler,
@@ -67,7 +67,8 @@ func NewRouter(
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authMiddleware) r.Use(authMiddleware)
r.Get("/ingredients/search", ingredientHandler.Search) r.Get("/products/search", productHandler.Search)
r.Get("/products/barcode/{barcode}", productHandler.GetByBarcode)
r.Get("/profile", userHandler.Get) r.Get("/profile", userHandler.Get)
r.Put("/profile", userHandler.Update) r.Put("/profile", userHandler.Update)
@@ -81,12 +82,12 @@ func NewRouter(
r.Delete("/{id}", savedRecipeHandler.Delete) r.Delete("/{id}", savedRecipeHandler.Delete)
}) })
r.Route("/products", func(r chi.Router) { r.Route("/user-products", func(r chi.Router) {
r.Get("/", productHandler.List) r.Get("/", userProductHandler.List)
r.Post("/", productHandler.Create) r.Post("/", userProductHandler.Create)
r.Post("/batch", productHandler.BatchCreate) r.Post("/batch", userProductHandler.BatchCreate)
r.Put("/{id}", productHandler.Update) r.Put("/{id}", userProductHandler.Update)
r.Delete("/{id}", productHandler.Delete) r.Delete("/{id}", userProductHandler.Delete)
}) })
r.Route("/dishes", func(r chi.Router) { r.Route("/dishes", func(r chi.Router) {

View File

@@ -44,11 +44,11 @@ $$ LANGUAGE plpgsql VOLATILE;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Enums -- Enums
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TYPE user_plan AS ENUM ('free', 'paid'); CREATE TYPE user_plan AS ENUM ('free', 'paid');
CREATE TYPE user_gender AS ENUM ('male', 'female'); CREATE TYPE user_gender AS ENUM ('male', 'female');
CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain'); CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain');
CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high'); CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high');
CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user'); CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user');
CREATE TYPE recipe_difficulty AS ENUM ('easy', 'medium', 'hard'); CREATE TYPE recipe_difficulty AS ENUM ('easy', 'medium', 'hard');
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -104,28 +104,30 @@ CREATE TABLE unit_translations (
); );
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- ingredient_categories + translations -- product_categories + product_category_translations
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE ingredient_categories ( CREATE TABLE product_categories (
slug VARCHAR(50) PRIMARY KEY, slug VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL,
sort_order SMALLINT NOT NULL DEFAULT 0 sort_order SMALLINT NOT NULL DEFAULT 0
); );
CREATE TABLE ingredient_category_translations ( CREATE TABLE product_category_translations (
category_slug VARCHAR(50) NOT NULL REFERENCES ingredient_categories(slug) ON DELETE CASCADE, product_category_slug VARCHAR(50) NOT NULL REFERENCES product_categories(slug) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL, lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
PRIMARY KEY (category_slug, lang) PRIMARY KEY (product_category_slug, lang)
); );
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- ingredients (canonical catalog) -- products (canonical catalog)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE ingredients ( CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
canonical_name VARCHAR(255) NOT NULL, canonical_name VARCHAR(255) NOT NULL,
category VARCHAR(50) REFERENCES ingredient_categories(slug), category VARCHAR(50) REFERENCES product_categories(slug),
default_unit VARCHAR(20) REFERENCES units(code), default_unit VARCHAR(20) REFERENCES units(code),
barcode TEXT UNIQUE,
calories_per_100g DECIMAL(8,2), calories_per_100g DECIMAL(8,2),
protein_per_100g DECIMAL(8,2), protein_per_100g DECIMAL(8,2),
fat_per_100g DECIMAL(8,2), fat_per_100g DECIMAL(8,2),
@@ -134,33 +136,23 @@ CREATE TABLE ingredients (
storage_days INTEGER, storage_days INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_ingredient_canonical_name UNIQUE (canonical_name) CONSTRAINT uq_product_canonical_name UNIQUE (canonical_name)
); );
CREATE INDEX idx_ingredients_canonical_name ON ingredients (canonical_name); CREATE INDEX idx_products_canonical_name ON products (canonical_name);
CREATE INDEX idx_ingredients_category ON ingredients (category); CREATE INDEX idx_products_category ON products (category);
CREATE INDEX idx_products_barcode ON products (barcode) WHERE barcode IS NOT NULL;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- ingredient_translations -- product_aliases
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE ingredient_translations ( CREATE TABLE product_aliases (
ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL, lang VARCHAR(10) NOT NULL,
name VARCHAR(255) NOT NULL, alias TEXT NOT NULL,
PRIMARY KEY (ingredient_id, lang) PRIMARY KEY (product_id, lang, alias)
); );
CREATE INDEX idx_ingredient_translations_ingredient_id ON ingredient_translations (ingredient_id); CREATE INDEX idx_product_aliases_lookup ON product_aliases (product_id, lang);
CREATE INDEX idx_product_aliases_trgm ON product_aliases USING GIN (alias gin_trgm_ops);
-- ---------------------------------------------------------------------------
-- ingredient_aliases
-- ---------------------------------------------------------------------------
CREATE TABLE ingredient_aliases (
ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
alias TEXT NOT NULL,
PRIMARY KEY (ingredient_id, lang, alias)
);
CREATE INDEX idx_ingredient_aliases_lookup ON ingredient_aliases (ingredient_id, lang);
CREATE INDEX idx_ingredient_aliases_trgm ON ingredient_aliases USING GIN (alias gin_trgm_ops);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- cuisines + cuisine_translations -- cuisines + cuisine_translations
@@ -279,22 +271,22 @@ CREATE TABLE recipe_translations (
); );
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- recipe_ingredients + recipe_ingredient_translations -- recipe_products + recipe_product_translations
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE recipe_ingredients ( CREATE TABLE recipe_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
ingredient_id UUID REFERENCES ingredients(id) ON DELETE SET NULL, product_id UUID REFERENCES products(id) ON DELETE SET NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
amount DECIMAL(10,2) NOT NULL DEFAULT 0, amount DECIMAL(10,2) NOT NULL DEFAULT 0,
unit_code VARCHAR(20), unit_code VARCHAR(20),
is_optional BOOLEAN NOT NULL DEFAULT false, is_optional BOOLEAN NOT NULL DEFAULT false,
sort_order SMALLINT NOT NULL DEFAULT 0 sort_order SMALLINT NOT NULL DEFAULT 0
); );
CREATE INDEX idx_recipe_ingredients_recipe_id ON recipe_ingredients (recipe_id); CREATE INDEX idx_recipe_products_recipe_id ON recipe_products (recipe_id);
CREATE TABLE recipe_ingredient_translations ( CREATE TABLE recipe_product_translations (
ri_id UUID NOT NULL REFERENCES recipe_ingredients(id) ON DELETE CASCADE, ri_id UUID NOT NULL REFERENCES recipe_products(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL, lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
PRIMARY KEY (ri_id, lang) PRIMARY KEY (ri_id, lang)
@@ -322,29 +314,29 @@ CREATE TABLE recipe_step_translations (
); );
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- products (user fridge / pantry items) -- user_products (user fridge / pantry items)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE products ( CREATE TABLE user_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
primary_ingredient_id UUID REFERENCES ingredients(id), primary_product_id UUID REFERENCES products(id),
name TEXT NOT NULL, name TEXT NOT NULL,
quantity DECIMAL(10,2) NOT NULL DEFAULT 1, quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
unit TEXT NOT NULL DEFAULT 'pcs' REFERENCES units(code), unit TEXT NOT NULL DEFAULT 'pcs' REFERENCES units(code),
category TEXT, category TEXT,
storage_days INT NOT NULL DEFAULT 7, storage_days INT NOT NULL DEFAULT 7,
added_at TIMESTAMPTZ NOT NULL DEFAULT now() added_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX idx_products_user_id ON products (user_id); CREATE INDEX idx_user_products_user_id ON user_products (user_id);
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- product_ingredients (M2M: composite product ↔ ingredients) -- user_product_components (M2M: composite user product ↔ catalog products)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE product_ingredients ( CREATE TABLE user_product_components (
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, user_product_id UUID NOT NULL REFERENCES user_products(id) ON DELETE CASCADE,
ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
amount_per_100g DECIMAL(10,2), amount_per_100g DECIMAL(10,2),
PRIMARY KEY (product_id, ingredient_id) PRIMARY KEY (user_product_id, product_id)
); );
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -394,21 +386,21 @@ CREATE TABLE shopping_lists (
-- dish_recognition_jobs -- dish_recognition_jobs
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE dish_recognition_jobs ( CREATE TABLE dish_recognition_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_plan TEXT NOT NULL, user_plan TEXT NOT NULL,
image_base64 TEXT NOT NULL, image_base64 TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'image/jpeg', mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
lang TEXT NOT NULL DEFAULT 'en', lang TEXT NOT NULL DEFAULT 'en',
target_date DATE, target_date DATE,
target_meal_type TEXT, target_meal_type TEXT,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
-- pending | processing | done | failed -- pending | processing | done | failed
result JSONB, result JSONB,
error TEXT, error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ, started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ completed_at TIMESTAMPTZ
); );
CREATE INDEX idx_dish_recognition_jobs_user CREATE INDEX idx_dish_recognition_jobs_user
ON dish_recognition_jobs (user_id, created_at DESC); ON dish_recognition_jobs (user_id, created_at DESC);
@@ -420,16 +412,18 @@ CREATE INDEX idx_dish_recognition_jobs_pending
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE meal_diary ( CREATE TABLE meal_diary (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL, date DATE NOT NULL,
meal_type TEXT NOT NULL, meal_type TEXT NOT NULL,
portions DECIMAL(5,2) NOT NULL DEFAULT 1, portions DECIMAL(5,2) NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'manual', source TEXT NOT NULL DEFAULT 'manual',
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT, dish_id UUID REFERENCES dishes(id) ON DELETE RESTRICT,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, product_id UUID REFERENCES products(id) ON DELETE RESTRICT,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
portion_g DECIMAL(10,2), portion_g DECIMAL(10,2),
job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL, job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_meal_entry_source CHECK (num_nonnulls(dish_id, product_id) = 1)
); );
CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date); CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date);
CREATE INDEX idx_meal_diary_job_id ON meal_diary (job_id) WHERE job_id IS NOT NULL; CREATE INDEX idx_meal_diary_job_id ON meal_diary (job_id) WHERE job_id IS NOT NULL;
@@ -471,18 +465,18 @@ INSERT INTO unit_translations (unit_code, lang, name) VALUES
('pack', 'ru', 'уп'); ('pack', 'ru', 'уп');
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Seed data: ingredient_categories + ingredient_category_translations -- Seed data: product_categories + product_category_translations
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
INSERT INTO ingredient_categories (slug, sort_order) VALUES INSERT INTO product_categories (slug, name, sort_order) VALUES
('dairy', 1), ('dairy', 'Dairy', 1),
('meat', 2), ('meat', 'Meat', 2),
('produce', 3), ('produce', 'Produce', 3),
('bakery', 4), ('bakery', 'Bakery', 4),
('frozen', 5), ('frozen', 'Frozen', 5),
('beverages', 6), ('beverages', 'Beverages', 6),
('other', 7); ('other', 'Other', 7);
INSERT INTO ingredient_category_translations (category_slug, lang, name) VALUES INSERT INTO product_category_translations (product_category_slug, lang, name) VALUES
('dairy', 'ru', 'Молочные продукты'), ('dairy', 'ru', 'Молочные продукты'),
('meat', 'ru', 'Мясо и птица'), ('meat', 'ru', 'Мясо и птица'),
('produce', 'ru', 'Овощи и фрукты'), ('produce', 'ru', 'Овощи и фрукты'),
@@ -617,12 +611,12 @@ DROP TABLE IF EXISTS shopping_lists;
DROP TABLE IF EXISTS menu_items; DROP TABLE IF EXISTS menu_items;
DROP TABLE IF EXISTS menu_plans; DROP TABLE IF EXISTS menu_plans;
DROP TABLE IF EXISTS user_saved_recipes; DROP TABLE IF EXISTS user_saved_recipes;
DROP TABLE IF EXISTS product_ingredients; DROP TABLE IF EXISTS user_product_components;
DROP TABLE IF EXISTS products; DROP TABLE IF EXISTS user_products;
DROP TABLE IF EXISTS recipe_step_translations; DROP TABLE IF EXISTS recipe_step_translations;
DROP TABLE IF EXISTS recipe_steps; DROP TABLE IF EXISTS recipe_steps;
DROP TABLE IF EXISTS recipe_ingredient_translations; DROP TABLE IF EXISTS recipe_product_translations;
DROP TABLE IF EXISTS recipe_ingredients; DROP TABLE IF EXISTS recipe_products;
DROP TABLE IF EXISTS recipe_translations; DROP TABLE IF EXISTS recipe_translations;
DROP TABLE IF EXISTS recipes; DROP TABLE IF EXISTS recipes;
DROP TABLE IF EXISTS dish_tags; DROP TABLE IF EXISTS dish_tags;
@@ -634,11 +628,10 @@ DROP TABLE IF EXISTS tag_translations;
DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS cuisine_translations; DROP TABLE IF EXISTS cuisine_translations;
DROP TABLE IF EXISTS cuisines; DROP TABLE IF EXISTS cuisines;
DROP TABLE IF EXISTS ingredient_aliases; DROP TABLE IF EXISTS product_aliases;
DROP TABLE IF EXISTS ingredient_translations; DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS ingredients; DROP TABLE IF EXISTS product_category_translations;
DROP TABLE IF EXISTS ingredient_category_translations; DROP TABLE IF EXISTS product_categories;
DROP TABLE IF EXISTS ingredient_categories;
DROP TABLE IF EXISTS unit_translations; DROP TABLE IF EXISTS unit_translations;
DROP TABLE IF EXISTS units; DROP TABLE IF EXISTS units;
DROP TABLE IF EXISTS languages; DROP TABLE IF EXISTS languages;

View File

@@ -140,7 +140,7 @@ func TestCreate_Success(t *testing.T) {
Name: "Oatmeal", Name: "Oatmeal",
Portions: 1, Portions: 1,
Source: "manual", Source: "manual",
DishID: "dish-1", DishID: strPtr("dish-1"),
CreatedAt: time.Now(), CreatedAt: time.Now(),
}, nil }, nil
}, },
@@ -207,3 +207,5 @@ func TestDelete_Success(t *testing.T) {
t.Errorf("expected 204, got %d", recorder.Code) t.Errorf("expected 204, got %d", recorder.Code)
} }
} }
func strPtr(s string) *string { return &s }

View File

@@ -1,25 +1,44 @@
package ingredient_test package product_catalog_test
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/food-ai/backend/internal/domain/ingredient" "github.com/food-ai/backend/internal/domain/product"
"github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/infra/middleware"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// mockIngredientSearcher is an inline mock for ingredient.IngredientSearcher. // mockProductSearcher is an inline mock for product.ProductSearcher (catalog search only).
type mockIngredientSearcher struct { type mockProductSearcher struct {
searchFn func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) searchFn func(ctx context.Context, query string, limit int) ([]*product.Product, error)
} }
func (m *mockIngredientSearcher) Search(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { func (m *mockProductSearcher) Search(ctx context.Context, query string, limit int) ([]*product.Product, error) {
return m.searchFn(ctx, query, limit) if m.searchFn != nil {
return m.searchFn(ctx, query, limit)
}
return []*product.Product{}, nil
}
func (m *mockProductSearcher) GetByBarcode(_ context.Context, _ string) (*product.Product, error) {
return nil, errors.New("not implemented")
}
func (m *mockProductSearcher) UpsertByBarcode(_ context.Context, catalogProduct *product.Product) (*product.Product, error) {
return catalogProduct, nil
}
// mockOpenFoodFacts is an inline mock for product.OpenFoodFactsClient (unused in search tests).
type mockOpenFoodFacts struct{}
func (m *mockOpenFoodFacts) Fetch(_ context.Context, _ string) (*product.Product, error) {
return nil, errors.New("not implemented")
} }
type alwaysAuthValidator struct{ userID string } type alwaysAuthValidator struct{ userID string }
@@ -28,10 +47,10 @@ func (v *alwaysAuthValidator) ValidateAccessToken(_ string) (*middleware.TokenCl
return &middleware.TokenClaims{UserID: v.userID}, nil return &middleware.TokenClaims{UserID: v.userID}, nil
} }
func buildRouter(handler *ingredient.Handler) *chi.Mux { func buildRouter(handler *product.Handler) *chi.Mux {
router := chi.NewRouter() router := chi.NewRouter()
router.Use(middleware.Auth(&alwaysAuthValidator{userID: "user-1"})) router.Use(middleware.Auth(&alwaysAuthValidator{userID: "user-1"}))
router.Get("/ingredients/search", handler.Search) router.Get("/products/search", handler.Search)
return router return router
} }
@@ -43,11 +62,11 @@ func authorizedRequest(target string) *http.Request {
func TestSearch_EmptyQuery_ReturnsEmptyArray(t *testing.T) { func TestSearch_EmptyQuery_ReturnsEmptyArray(t *testing.T) {
// When q is empty, the handler returns [] without calling the repository. // When q is empty, the handler returns [] without calling the repository.
handler := ingredient.NewHandler(&mockIngredientSearcher{}) handler := product.NewHandler(&mockProductSearcher{}, &mockOpenFoodFacts{})
router := buildRouter(handler) router := buildRouter(handler)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest("/ingredients/search")) router.ServeHTTP(recorder, authorizedRequest("/products/search"))
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected 200, got %d", recorder.Code) t.Errorf("expected 200, got %d", recorder.Code)
@@ -60,17 +79,17 @@ func TestSearch_EmptyQuery_ReturnsEmptyArray(t *testing.T) {
func TestSearch_LimitTooLarge_UsesDefault(t *testing.T) { func TestSearch_LimitTooLarge_UsesDefault(t *testing.T) {
// When limit > 50, the handler ignores it and uses default 10. // When limit > 50, the handler ignores it and uses default 10.
calledLimit := 0 calledLimit := 0
mockRepo := &mockIngredientSearcher{ mockRepo := &mockProductSearcher{
searchFn: func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) {
calledLimit = limit calledLimit = limit
return []*ingredient.IngredientMapping{}, nil return []*product.Product{}, nil
}, },
} }
handler := ingredient.NewHandler(mockRepo) handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{})
router := buildRouter(handler) router := buildRouter(handler)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest("/ingredients/search?q=apple&limit=100")) router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple&limit=100"))
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected 200, got %d", recorder.Code) t.Errorf("expected 200, got %d", recorder.Code)
@@ -83,17 +102,17 @@ func TestSearch_LimitTooLarge_UsesDefault(t *testing.T) {
func TestSearch_DefaultLimit(t *testing.T) { func TestSearch_DefaultLimit(t *testing.T) {
// When no limit is supplied, the handler uses default 10. // When no limit is supplied, the handler uses default 10.
calledLimit := 0 calledLimit := 0
mockRepo := &mockIngredientSearcher{ mockRepo := &mockProductSearcher{
searchFn: func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) {
calledLimit = limit calledLimit = limit
return []*ingredient.IngredientMapping{}, nil return []*product.Product{}, nil
}, },
} }
handler := ingredient.NewHandler(mockRepo) handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{})
router := buildRouter(handler) router := buildRouter(handler)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest("/ingredients/search?q=apple")) router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple"))
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected 200, got %d", recorder.Code) t.Errorf("expected 200, got %d", recorder.Code)
@@ -106,17 +125,17 @@ func TestSearch_DefaultLimit(t *testing.T) {
func TestSearch_ValidLimit(t *testing.T) { func TestSearch_ValidLimit(t *testing.T) {
// limit=25 is within range and should be forwarded as-is. // limit=25 is within range and should be forwarded as-is.
calledLimit := 0 calledLimit := 0
mockRepo := &mockIngredientSearcher{ mockRepo := &mockProductSearcher{
searchFn: func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) {
calledLimit = limit calledLimit = limit
return []*ingredient.IngredientMapping{}, nil return []*product.Product{}, nil
}, },
} }
handler := ingredient.NewHandler(mockRepo) handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{})
router := buildRouter(handler) router := buildRouter(handler)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest("/ingredients/search?q=apple&limit=25")) router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple&limit=25"))
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected 200, got %d", recorder.Code) t.Errorf("expected 200, got %d", recorder.Code)
@@ -127,31 +146,31 @@ func TestSearch_ValidLimit(t *testing.T) {
} }
func TestSearch_Success(t *testing.T) { func TestSearch_Success(t *testing.T) {
mockRepo := &mockIngredientSearcher{ mockRepo := &mockProductSearcher{
searchFn: func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) {
return []*ingredient.IngredientMapping{ return []*product.Product{
{ID: "ing-1", CanonicalName: "apple"}, {ID: "prod-1", CanonicalName: "apple"},
}, nil }, nil
}, },
} }
handler := ingredient.NewHandler(mockRepo) handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{})
router := buildRouter(handler) router := buildRouter(handler)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest("/ingredients/search?q=apple")) router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple"))
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected 200, got %d", recorder.Code) t.Errorf("expected 200, got %d", recorder.Code)
} }
var mappings []ingredient.IngredientMapping var products []product.Product
if decodeError := json.NewDecoder(recorder.Body).Decode(&mappings); decodeError != nil { if decodeError := json.NewDecoder(recorder.Body).Decode(&products); decodeError != nil {
t.Fatalf("decode response: %v", decodeError) t.Fatalf("decode response: %v", decodeError)
} }
if len(mappings) != 1 { if len(products) != 1 {
t.Errorf("expected 1 result, got %d", len(mappings)) t.Errorf("expected 1 result, got %d", len(products))
} }
if mappings[0].CanonicalName != "apple" { if products[0].CanonicalName != "apple" {
t.Errorf("expected canonical_name=apple, got %q", mappings[0].CanonicalName) t.Errorf("expected canonical_name=apple, got %q", products[0].CanonicalName)
} }
} }

View File

@@ -1,33 +1,33 @@
//go:build integration //go:build integration
package ingredient_test package product_catalog_test
import ( import (
"context" "context"
"testing" "testing"
"github.com/food-ai/backend/internal/domain/ingredient" "github.com/food-ai/backend/internal/domain/product"
"github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/locale"
"github.com/food-ai/backend/internal/testutil" "github.com/food-ai/backend/internal/testutil"
) )
func TestIngredientRepository_Upsert_Insert(t *testing.T) { func TestProductRepository_Upsert_Insert(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := ingredient.NewRepository(pool) repo := product.NewRepository(pool)
ctx := context.Background() ctx := context.Background()
cat := "produce" cat := "produce"
unit := "g" unit := "g"
cal := 52.0 cal := 52.0
mapping := &ingredient.IngredientMapping{ catalogProduct := &product.Product{
CanonicalName: "apple", CanonicalName: "apple",
Category: &cat, Category: &cat,
DefaultUnit: &unit, DefaultUnit: &unit,
CaloriesPer100g: &cal, CaloriesPer100g: &cal,
} }
got, upsertError := repo.Upsert(ctx, mapping) got, upsertError := repo.Upsert(ctx, catalogProduct)
if upsertError != nil { if upsertError != nil {
t.Fatalf("upsert: %v", upsertError) t.Fatalf("upsert: %v", upsertError)
} }
@@ -42,15 +42,15 @@ func TestIngredientRepository_Upsert_Insert(t *testing.T) {
} }
} }
func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) { func TestProductRepository_Upsert_ConflictUpdates(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := ingredient.NewRepository(pool) repo := product.NewRepository(pool)
ctx := context.Background() ctx := context.Background()
cat := "produce" cat := "produce"
unit := "g" unit := "g"
first := &ingredient.IngredientMapping{ first := &product.Product{
CanonicalName: "banana", CanonicalName: "banana",
Category: &cat, Category: &cat,
DefaultUnit: &unit, DefaultUnit: &unit,
@@ -61,7 +61,7 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) {
} }
cal := 89.0 cal := 89.0
second := &ingredient.IngredientMapping{ second := &product.Product{
CanonicalName: "banana", CanonicalName: "banana",
Category: &cat, Category: &cat,
DefaultUnit: &unit, DefaultUnit: &unit,
@@ -83,15 +83,15 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) {
} }
} }
func TestIngredientRepository_GetByID_Found(t *testing.T) { func TestProductRepository_GetByID_Found(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := ingredient.NewRepository(pool) repo := product.NewRepository(pool)
ctx := context.Background() ctx := context.Background()
cat := "dairy" cat := "dairy"
unit := "g" unit := "g"
saved, upsertError := repo.Upsert(ctx, &ingredient.IngredientMapping{ saved, upsertError := repo.Upsert(ctx, &product.Product{
CanonicalName: "cheese", CanonicalName: "cheese",
Category: &cat, Category: &cat,
DefaultUnit: &unit, DefaultUnit: &unit,
@@ -112,9 +112,9 @@ func TestIngredientRepository_GetByID_Found(t *testing.T) {
} }
} }
func TestIngredientRepository_GetByID_NotFound(t *testing.T) { func TestProductRepository_GetByID_NotFound(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := ingredient.NewRepository(pool) repo := product.NewRepository(pool)
ctx := context.Background() ctx := context.Background()
got, getError := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000") got, getError := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000")
@@ -126,105 +126,17 @@ func TestIngredientRepository_GetByID_NotFound(t *testing.T) {
} }
} }
func TestIngredientRepository_ListMissingTranslation(t *testing.T) {
func TestProductRepository_UpsertAliases(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := ingredient.NewRepository(pool) repo := product.NewRepository(pool)
ctx := context.Background() ctx := context.Background()
cat := "produce" cat := "produce"
unit := "g" unit := "g"
// Insert 3 without any translation. saved, upsertError := repo.Upsert(ctx, &product.Product{
for _, name := range []string{"carrot", "onion", "garlic"} {
_, upsertError := repo.Upsert(ctx, &ingredient.IngredientMapping{
CanonicalName: name,
Category: &cat,
DefaultUnit: &unit,
})
if upsertError != nil {
t.Fatalf("upsert %s: %v", name, upsertError)
}
}
// Insert 1 and add a translation — should not appear in the result.
saved, upsertError := repo.Upsert(ctx, &ingredient.IngredientMapping{
CanonicalName: "tomato",
Category: &cat,
DefaultUnit: &unit,
})
if upsertError != nil {
t.Fatalf("upsert tomato: %v", upsertError)
}
if translationError := repo.UpsertTranslation(ctx, saved.ID, "ru", "помидор"); translationError != nil {
t.Fatalf("upsert translation: %v", translationError)
}
missing, listError := repo.ListMissingTranslation(ctx, "ru", 10, 0)
if listError != nil {
t.Fatalf("list missing translation: %v", listError)
}
for _, mapping := range missing {
if mapping.CanonicalName == "tomato" {
t.Error("translated ingredient should not appear in ListMissingTranslation")
}
}
if len(missing) < 3 {
t.Errorf("expected at least 3 untranslated, got %d", len(missing))
}
}
func TestIngredientRepository_UpsertTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := ingredient.NewRepository(pool)
ctx := context.Background()
cat := "meat"
unit := "g"
saved, upsertError := repo.Upsert(ctx, &ingredient.IngredientMapping{
CanonicalName: "chicken_breast",
Category: &cat,
DefaultUnit: &unit,
})
if upsertError != nil {
t.Fatalf("upsert: %v", upsertError)
}
if translationError := repo.UpsertTranslation(ctx, saved.ID, "ru", "куриная грудка"); translationError != nil {
t.Fatalf("upsert translation: %v", translationError)
}
// Retrieve with Russian context — CanonicalName should be the Russian name.
russianContext := locale.WithLang(ctx, "ru")
got, getError := repo.GetByID(russianContext, saved.ID)
if getError != nil {
t.Fatalf("get by id: %v", getError)
}
if got.CanonicalName != "куриная грудка" {
t.Errorf("expected CanonicalName='куриная грудка', got %q", got.CanonicalName)
}
// Retrieve with English context (default) — CanonicalName should be the English name.
englishContext := locale.WithLang(ctx, "en")
gotEnglish, getEnglishError := repo.GetByID(englishContext, saved.ID)
if getEnglishError != nil {
t.Fatalf("get by id (en): %v", getEnglishError)
}
if gotEnglish.CanonicalName != "chicken_breast" {
t.Errorf("expected English CanonicalName='chicken_breast', got %q", gotEnglish.CanonicalName)
}
}
func TestIngredientRepository_UpsertAliases(t *testing.T) {
pool := testutil.SetupTestDB(t)
repo := ingredient.NewRepository(pool)
ctx := context.Background()
cat := "produce"
unit := "g"
saved, upsertError := repo.Upsert(ctx, &ingredient.IngredientMapping{
CanonicalName: "apple_test_aliases", CanonicalName: "apple_test_aliases",
Category: &cat, Category: &cat,
DefaultUnit: &unit, DefaultUnit: &unit,
@@ -238,12 +150,10 @@ func TestIngredientRepository_UpsertAliases(t *testing.T) {
t.Fatalf("upsert aliases: %v", aliasError) t.Fatalf("upsert aliases: %v", aliasError)
} }
// Idempotent — second call should not fail.
if aliasError := repo.UpsertAliases(ctx, saved.ID, "en", aliases); aliasError != nil { if aliasError := repo.UpsertAliases(ctx, saved.ID, "en", aliases); aliasError != nil {
t.Fatalf("second upsert aliases: %v", aliasError) t.Fatalf("second upsert aliases: %v", aliasError)
} }
// Retrieve with English context — aliases should appear.
englishContext := locale.WithLang(ctx, "en") englishContext := locale.WithLang(ctx, "en")
got, getError := repo.GetByID(englishContext, saved.ID) got, getError := repo.GetByID(englishContext, saved.ID)
if getError != nil { if getError != nil {
@@ -254,15 +164,14 @@ func TestIngredientRepository_UpsertAliases(t *testing.T) {
} }
} }
func TestIngredientRepository_UpsertCategoryTranslation(t *testing.T) { func TestProductRepository_UpsertCategoryTranslation(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := ingredient.NewRepository(pool) repo := product.NewRepository(pool)
ctx := context.Background() ctx := context.Background()
// Upsert an ingredient with a known category.
cat := "dairy" cat := "dairy"
unit := "g" unit := "g"
saved, upsertError := repo.Upsert(ctx, &ingredient.IngredientMapping{ saved, upsertError := repo.Upsert(ctx, &product.Product{
CanonicalName: "milk_test_category", CanonicalName: "milk_test_category",
Category: &cat, Category: &cat,
DefaultUnit: &unit, DefaultUnit: &unit,
@@ -271,8 +180,6 @@ func TestIngredientRepository_UpsertCategoryTranslation(t *testing.T) {
t.Fatalf("upsert: %v", upsertError) t.Fatalf("upsert: %v", upsertError)
} }
// The migration already inserted Russian translations for known categories.
// Retrieve with Russian context — CategoryName should be set.
russianContext := locale.WithLang(ctx, "ru") russianContext := locale.WithLang(ctx, "ru")
got, getError := repo.GetByID(russianContext, saved.ID) got, getError := repo.GetByID(russianContext, saved.ID)
if getError != nil { if getError != nil {
@@ -282,7 +189,6 @@ func TestIngredientRepository_UpsertCategoryTranslation(t *testing.T) {
t.Error("expected non-empty CategoryName for 'dairy' in Russian") t.Error("expected non-empty CategoryName for 'dairy' in Russian")
} }
// Upsert a new translation and verify it is returned.
if translationError := repo.UpsertCategoryTranslation(ctx, "dairy", "de", "Milchprodukte"); translationError != nil { if translationError := repo.UpsertCategoryTranslation(ctx, "dairy", "de", "Milchprodukte"); translationError != nil {
t.Fatalf("upsert category translation: %v", translationError) t.Fatalf("upsert category translation: %v", translationError)
} }

View File

@@ -1,4 +1,4 @@
package product_test package userproduct_test
import ( import (
"bytes" "bytes"
@@ -9,8 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/food-ai/backend/internal/domain/product" "github.com/food-ai/backend/internal/domain/userproduct"
productmocks "github.com/food-ai/backend/internal/domain/product/mocks" userproductmocks "github.com/food-ai/backend/internal/domain/userproduct/mocks"
"github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/infra/middleware"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -21,14 +21,14 @@ func (v *alwaysAuthValidator) ValidateAccessToken(_ string) (*middleware.TokenCl
return &middleware.TokenClaims{UserID: v.userID}, nil return &middleware.TokenClaims{UserID: v.userID}, nil
} }
func buildRouter(handler *product.Handler, userID string) *chi.Mux { func buildRouter(handler *userproduct.Handler, userID string) *chi.Mux {
router := chi.NewRouter() router := chi.NewRouter()
router.Use(middleware.Auth(&alwaysAuthValidator{userID: userID})) router.Use(middleware.Auth(&alwaysAuthValidator{userID: userID}))
router.Get("/products", handler.List) router.Get("/user-products", handler.List)
router.Post("/products", handler.Create) router.Post("/user-products", handler.Create)
router.Post("/products/batch", handler.BatchCreate) router.Post("/user-products/batch", handler.BatchCreate)
router.Put("/products/{id}", handler.Update) router.Put("/user-products/{id}", handler.Update)
router.Delete("/products/{id}", handler.Delete) router.Delete("/user-products/{id}", handler.Delete)
return router return router
} }
@@ -39,8 +39,8 @@ func authorizedRequest(method, target string, body []byte) *http.Request {
return request return request
} }
func makeProduct(name string) *product.Product { func makeUserProduct(name string) *userproduct.UserProduct {
return &product.Product{ return &userproduct.UserProduct{
ID: "prod-1", ID: "prod-1",
UserID: "user-1", UserID: "user-1",
Name: name, Name: name,
@@ -54,16 +54,16 @@ func makeProduct(name string) *product.Product {
} }
func TestList_Success(t *testing.T) { func TestList_Success(t *testing.T) {
mockRepo := &productmocks.MockProductRepository{ mockRepo := &userproductmocks.MockUserProductRepository{
ListFn: func(ctx context.Context, userID string) ([]*product.Product, error) { ListFn: func(ctx context.Context, userID string) ([]*userproduct.UserProduct, error) {
return []*product.Product{makeProduct("Milk")}, nil return []*userproduct.UserProduct{makeUserProduct("Milk")}, nil
}, },
} }
handler := product.NewHandler(mockRepo) handler := userproduct.NewHandler(mockRepo)
router := buildRouter(handler, "user-1") router := buildRouter(handler, "user-1")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest(http.MethodGet, "/products", nil)) router.ServeHTTP(recorder, authorizedRequest(http.MethodGet, "/user-products", nil))
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected 200, got %d", recorder.Code) t.Errorf("expected 200, got %d", recorder.Code)
@@ -71,12 +71,12 @@ func TestList_Success(t *testing.T) {
} }
func TestCreate_MissingName(t *testing.T) { func TestCreate_MissingName(t *testing.T) {
handler := product.NewHandler(&productmocks.MockProductRepository{}) handler := userproduct.NewHandler(&userproductmocks.MockUserProductRepository{})
router := buildRouter(handler, "user-1") router := buildRouter(handler, "user-1")
body, _ := json.Marshal(map[string]any{"quantity": 1}) body, _ := json.Marshal(map[string]any{"quantity": 1})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest(http.MethodPost, "/products", body)) router.ServeHTTP(recorder, authorizedRequest(http.MethodPost, "/user-products", body))
if recorder.Code != http.StatusBadRequest { if recorder.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", recorder.Code) t.Errorf("expected 400, got %d", recorder.Code)
@@ -84,17 +84,17 @@ func TestCreate_MissingName(t *testing.T) {
} }
func TestCreate_Success(t *testing.T) { func TestCreate_Success(t *testing.T) {
mockRepo := &productmocks.MockProductRepository{ mockRepo := &userproductmocks.MockUserProductRepository{
CreateFn: func(ctx context.Context, userID string, req product.CreateRequest) (*product.Product, error) { CreateFn: func(ctx context.Context, userID string, req userproduct.CreateRequest) (*userproduct.UserProduct, error) {
return makeProduct(req.Name), nil return makeUserProduct(req.Name), nil
}, },
} }
handler := product.NewHandler(mockRepo) handler := userproduct.NewHandler(mockRepo)
router := buildRouter(handler, "user-1") router := buildRouter(handler, "user-1")
body, _ := json.Marshal(product.CreateRequest{Name: "Milk", Quantity: 1, Unit: "L"}) body, _ := json.Marshal(userproduct.CreateRequest{Name: "Milk", Quantity: 1, Unit: "L"})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest(http.MethodPost, "/products", body)) router.ServeHTTP(recorder, authorizedRequest(http.MethodPost, "/user-products", body))
if recorder.Code != http.StatusCreated { if recorder.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", recorder.Code) t.Errorf("expected 201, got %d", recorder.Code)
@@ -102,24 +102,24 @@ func TestCreate_Success(t *testing.T) {
} }
func TestBatchCreate_Success(t *testing.T) { func TestBatchCreate_Success(t *testing.T) {
mockRepo := &productmocks.MockProductRepository{ mockRepo := &userproductmocks.MockUserProductRepository{
BatchCreateFn: func(ctx context.Context, userID string, items []product.CreateRequest) ([]*product.Product, error) { BatchCreateFn: func(ctx context.Context, userID string, items []userproduct.CreateRequest) ([]*userproduct.UserProduct, error) {
result := make([]*product.Product, len(items)) result := make([]*userproduct.UserProduct, len(items))
for index, item := range items { for index, item := range items {
result[index] = makeProduct(item.Name) result[index] = makeUserProduct(item.Name)
} }
return result, nil return result, nil
}, },
} }
handler := product.NewHandler(mockRepo) handler := userproduct.NewHandler(mockRepo)
router := buildRouter(handler, "user-1") router := buildRouter(handler, "user-1")
body, _ := json.Marshal([]product.CreateRequest{ body, _ := json.Marshal([]userproduct.CreateRequest{
{Name: "Milk", Quantity: 1, Unit: "L"}, {Name: "Milk", Quantity: 1, Unit: "L"},
{Name: "Eggs", Quantity: 12, Unit: "pcs"}, {Name: "Eggs", Quantity: 12, Unit: "pcs"},
}) })
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest(http.MethodPost, "/products/batch", body)) router.ServeHTTP(recorder, authorizedRequest(http.MethodPost, "/user-products/batch", body))
if recorder.Code != http.StatusCreated { if recorder.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", recorder.Code) t.Errorf("expected 201, got %d", recorder.Code)
@@ -127,18 +127,18 @@ func TestBatchCreate_Success(t *testing.T) {
} }
func TestUpdate_NotFound(t *testing.T) { func TestUpdate_NotFound(t *testing.T) {
mockRepo := &productmocks.MockProductRepository{ mockRepo := &userproductmocks.MockUserProductRepository{
UpdateFn: func(ctx context.Context, id, userID string, req product.UpdateRequest) (*product.Product, error) { UpdateFn: func(ctx context.Context, id, userID string, req userproduct.UpdateRequest) (*userproduct.UserProduct, error) {
return nil, product.ErrNotFound return nil, userproduct.ErrNotFound
}, },
} }
handler := product.NewHandler(mockRepo) handler := userproduct.NewHandler(mockRepo)
router := buildRouter(handler, "user-1") router := buildRouter(handler, "user-1")
namePtr := "NewName" namePtr := "NewName"
body, _ := json.Marshal(product.UpdateRequest{Name: &namePtr}) body, _ := json.Marshal(userproduct.UpdateRequest{Name: &namePtr})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest(http.MethodPut, "/products/nonexistent", body)) router.ServeHTTP(recorder, authorizedRequest(http.MethodPut, "/user-products/nonexistent", body))
if recorder.Code != http.StatusNotFound { if recorder.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", recorder.Code) t.Errorf("expected 404, got %d", recorder.Code)
@@ -146,19 +146,19 @@ func TestUpdate_NotFound(t *testing.T) {
} }
func TestUpdate_Success(t *testing.T) { func TestUpdate_Success(t *testing.T) {
mockRepo := &productmocks.MockProductRepository{ mockRepo := &userproductmocks.MockUserProductRepository{
UpdateFn: func(ctx context.Context, id, userID string, req product.UpdateRequest) (*product.Product, error) { UpdateFn: func(ctx context.Context, id, userID string, req userproduct.UpdateRequest) (*userproduct.UserProduct, error) {
updated := makeProduct(*req.Name) updated := makeUserProduct(*req.Name)
return updated, nil return updated, nil
}, },
} }
handler := product.NewHandler(mockRepo) handler := userproduct.NewHandler(mockRepo)
router := buildRouter(handler, "user-1") router := buildRouter(handler, "user-1")
namePtr := "Oat Milk" namePtr := "Oat Milk"
body, _ := json.Marshal(product.UpdateRequest{Name: &namePtr}) body, _ := json.Marshal(userproduct.UpdateRequest{Name: &namePtr})
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest(http.MethodPut, "/products/prod-1", body)) router.ServeHTTP(recorder, authorizedRequest(http.MethodPut, "/user-products/prod-1", body))
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected 200, got %d", recorder.Code) t.Errorf("expected 200, got %d", recorder.Code)
@@ -166,16 +166,16 @@ func TestUpdate_Success(t *testing.T) {
} }
func TestDelete_NotFound(t *testing.T) { func TestDelete_NotFound(t *testing.T) {
mockRepo := &productmocks.MockProductRepository{ mockRepo := &userproductmocks.MockUserProductRepository{
DeleteFn: func(ctx context.Context, id, userID string) error { DeleteFn: func(ctx context.Context, id, userID string) error {
return product.ErrNotFound return userproduct.ErrNotFound
}, },
} }
handler := product.NewHandler(mockRepo) handler := userproduct.NewHandler(mockRepo)
router := buildRouter(handler, "user-1") router := buildRouter(handler, "user-1")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest(http.MethodDelete, "/products/nonexistent", nil)) router.ServeHTTP(recorder, authorizedRequest(http.MethodDelete, "/user-products/nonexistent", nil))
if recorder.Code != http.StatusNotFound { if recorder.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", recorder.Code) t.Errorf("expected 404, got %d", recorder.Code)
@@ -183,16 +183,16 @@ func TestDelete_NotFound(t *testing.T) {
} }
func TestDelete_Success(t *testing.T) { func TestDelete_Success(t *testing.T) {
mockRepo := &productmocks.MockProductRepository{ mockRepo := &userproductmocks.MockUserProductRepository{
DeleteFn: func(ctx context.Context, id, userID string) error { DeleteFn: func(ctx context.Context, id, userID string) error {
return nil return nil
}, },
} }
handler := product.NewHandler(mockRepo) handler := userproduct.NewHandler(mockRepo)
router := buildRouter(handler, "user-1") router := buildRouter(handler, "user-1")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, authorizedRequest(http.MethodDelete, "/products/prod-1", nil)) router.ServeHTTP(recorder, authorizedRequest(http.MethodDelete, "/user-products/prod-1", nil))
if recorder.Code != http.StatusNoContent { if recorder.Code != http.StatusNoContent {
t.Errorf("expected 204, got %d", recorder.Code) t.Errorf("expected 204, got %d", recorder.Code)

View File

@@ -1,29 +1,29 @@
//go:build integration //go:build integration
package product_test package userproduct_test
import ( import (
"context" "context"
"testing" "testing"
"github.com/food-ai/backend/internal/domain/product" "github.com/food-ai/backend/internal/domain/userproduct"
"github.com/food-ai/backend/internal/testutil" "github.com/food-ai/backend/internal/testutil"
) )
func TestProductRepository_Create_Defaults(t *testing.T) { func TestUserProductRepository_Create_Defaults(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := product.NewRepository(pool) repo := userproduct.NewRepository(pool)
requestContext := context.Background() requestContext := context.Background()
// storage_days=0 → 7; unit="" → "pcs"; quantity=0 → 1 // storage_days=0 → 7; unit="" → "pcs"; quantity=0 → 1
created, createError := repo.Create(requestContext, "test-user", product.CreateRequest{ created, createError := repo.Create(requestContext, "test-user", userproduct.CreateRequest{
Name: "Milk", Name: "Milk",
StorageDays: 0, StorageDays: 0,
Unit: "", Unit: "",
Quantity: 0, Quantity: 0,
}) })
if createError != nil { if createError != nil {
t.Fatalf("create product: %v", createError) t.Fatalf("create user product: %v", createError)
} }
if created.StorageDays != 7 { if created.StorageDays != 7 {
t.Errorf("expected storage_days=7, got %d", created.StorageDays) t.Errorf("expected storage_days=7, got %d", created.StorageDays)
@@ -36,25 +36,24 @@ func TestProductRepository_Create_Defaults(t *testing.T) {
} }
} }
func TestProductRepository_List_OrderByExpiry(t *testing.T) { func TestUserProductRepository_List_OrderByExpiry(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := product.NewRepository(pool) repo := userproduct.NewRepository(pool)
requestContext := context.Background() requestContext := context.Background()
userID := "list-order-user" userID := "list-order-user"
// Create product with longer expiry first, shorter expiry second. _, createError := repo.Create(requestContext, userID, userproduct.CreateRequest{Name: "Milk", StorageDays: 14})
_, createError := repo.Create(requestContext, userID, product.CreateRequest{Name: "Milk", StorageDays: 14})
if createError != nil { if createError != nil {
t.Fatalf("create Milk: %v", createError) t.Fatalf("create Milk: %v", createError)
} }
_, createError = repo.Create(requestContext, userID, product.CreateRequest{Name: "Butter", StorageDays: 3}) _, createError = repo.Create(requestContext, userID, userproduct.CreateRequest{Name: "Butter", StorageDays: 3})
if createError != nil { if createError != nil {
t.Fatalf("create Butter: %v", createError) t.Fatalf("create Butter: %v", createError)
} }
products, listError := repo.List(requestContext, userID) products, listError := repo.List(requestContext, userID)
if listError != nil { if listError != nil {
t.Fatalf("list products: %v", listError) t.Fatalf("list user products: %v", listError)
} }
if len(products) != 2 { if len(products) != 2 {
t.Fatalf("expected 2 products, got %d", len(products)) t.Fatalf("expected 2 products, got %d", len(products))
@@ -65,12 +64,12 @@ func TestProductRepository_List_OrderByExpiry(t *testing.T) {
} }
} }
func TestProductRepository_BatchCreate(t *testing.T) { func TestUserProductRepository_BatchCreate(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := product.NewRepository(pool) repo := userproduct.NewRepository(pool)
requestContext := context.Background() requestContext := context.Background()
products, batchError := repo.BatchCreate(requestContext, "batch-user", []product.CreateRequest{ products, batchError := repo.BatchCreate(requestContext, "batch-user", []userproduct.CreateRequest{
{Name: "Eggs", Quantity: 12, Unit: "pcs"}, {Name: "Eggs", Quantity: 12, Unit: "pcs"},
{Name: "Flour", Quantity: 500, Unit: "g"}, {Name: "Flour", Quantity: 500, Unit: "g"},
}) })
@@ -82,46 +81,46 @@ func TestProductRepository_BatchCreate(t *testing.T) {
} }
} }
func TestProductRepository_Update_NotFound(t *testing.T) { func TestUserProductRepository_Update_NotFound(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := product.NewRepository(pool) repo := userproduct.NewRepository(pool)
requestContext := context.Background() requestContext := context.Background()
newName := "Ghost" newName := "Ghost"
_, updateError := repo.Update(requestContext, "00000000-0000-0000-0000-000000000000", "any-user", _, updateError := repo.Update(requestContext, "00000000-0000-0000-0000-000000000000", "any-user",
product.UpdateRequest{Name: &newName}) userproduct.UpdateRequest{Name: &newName})
if updateError != product.ErrNotFound { if updateError != userproduct.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", updateError) t.Errorf("expected ErrNotFound, got %v", updateError)
} }
} }
func TestProductRepository_Delete_WrongUser(t *testing.T) { func TestUserProductRepository_Delete_WrongUser(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := product.NewRepository(pool) repo := userproduct.NewRepository(pool)
requestContext := context.Background() requestContext := context.Background()
created, createError := repo.Create(requestContext, "owner-user", product.CreateRequest{Name: "Cheese"}) created, createError := repo.Create(requestContext, "owner-user", userproduct.CreateRequest{Name: "Cheese"})
if createError != nil { if createError != nil {
t.Fatalf("create product: %v", createError) t.Fatalf("create user product: %v", createError)
} }
deleteError := repo.Delete(requestContext, created.ID, "other-user") deleteError := repo.Delete(requestContext, created.ID, "other-user")
if deleteError != product.ErrNotFound { if deleteError != userproduct.ErrNotFound {
t.Errorf("expected ErrNotFound when deleting another user's product, got %v", deleteError) t.Errorf("expected ErrNotFound when deleting another user's product, got %v", deleteError)
} }
} }
func TestProductRepository_ListForPrompt(t *testing.T) { func TestUserProductRepository_ListForPrompt(t *testing.T) {
pool := testutil.SetupTestDB(t) pool := testutil.SetupTestDB(t)
repo := product.NewRepository(pool) repo := userproduct.NewRepository(pool)
requestContext := context.Background() requestContext := context.Background()
userID := "prompt-user" userID := "prompt-user"
_, createError := repo.Create(requestContext, userID, product.CreateRequest{ _, createError := repo.Create(requestContext, userID, userproduct.CreateRequest{
Name: "Tomatoes", Quantity: 4, Unit: "pcs", StorageDays: 5, Name: "Tomatoes", Quantity: 4, Unit: "pcs", StorageDays: 5,
}) })
if createError != nil { if createError != nil {
t.Fatalf("create product: %v", createError) t.Fatalf("create user product: %v", createError)
} }
lines, listError := repo.ListForPrompt(requestContext, userID) lines, listError := repo.ListForPrompt(requestContext, userID)

View File

@@ -21,7 +21,7 @@ import '../../features/menu/shopping_list_screen.dart';
import '../../features/recipes/recipe_detail_screen.dart'; import '../../features/recipes/recipe_detail_screen.dart';
import '../../features/recipes/recipes_screen.dart'; import '../../features/recipes/recipes_screen.dart';
import '../../features/profile/profile_screen.dart'; import '../../features/profile/profile_screen.dart';
import '../../features/products/product_provider.dart'; import '../../features/products/user_product_provider.dart';
import '../../features/scan/recognition_history_screen.dart'; import '../../features/scan/recognition_history_screen.dart';
import '../../shared/models/recipe.dart'; import '../../shared/models/recipe.dart';
import '../../shared/models/saved_recipe.dart'; import '../../shared/models/saved_recipe.dart';
@@ -205,7 +205,7 @@ class MainShell extends ConsumerWidget {
final currentIndex = _tabs.indexOf(location).clamp(0, _tabs.length - 1); final currentIndex = _tabs.indexOf(location).clamp(0, _tabs.length - 1);
// Count products expiring soon for the badge. // Count products expiring soon for the badge.
final expiringCount = ref.watch(productsProvider).maybeWhen( final expiringCount = ref.watch(userProductsProvider).maybeWhen(
data: (products) => products.where((p) => p.expiringSoon).length, data: (products) => products.where((p) => p.expiringSoon).length,
orElse: () => 0, orElse: () => 0,
); );

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../core/auth/auth_provider.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/product.dart';
import 'food_product_service.dart';
import 'product_portion_sheet.dart';
/// Screen that activates the device camera to scan a barcode.
/// On successful scan it looks up the catalog product and shows
/// [ProductPortionSheet] to confirm the portion before adding to diary.
class BarcodeScanScreen extends ConsumerStatefulWidget {
const BarcodeScanScreen({
super.key,
required this.mealType,
required this.date,
required this.onAdded,
});
final String mealType;
final String date;
final VoidCallback onAdded;
@override
ConsumerState<BarcodeScanScreen> createState() => _BarcodeScanScreenState();
}
class _BarcodeScanScreenState extends ConsumerState<BarcodeScanScreen> {
bool _scanning = true;
void _onBarcodeDetected(BarcodeCapture capture) async {
if (!_scanning) return;
final rawValue = capture.barcodes.firstOrNull?.rawValue;
if (rawValue == null) return;
setState(() => _scanning = false);
final l10n = AppLocalizations.of(context)!;
final service = FoodProductService(ref.read(apiClientProvider));
final catalogProduct = await service.getByBarcode(rawValue);
if (!mounted) return;
if (catalogProduct == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.productNotFound)),
);
setState(() => _scanning = true);
return;
}
_showPortionSheet(catalogProduct);
}
void _showPortionSheet(CatalogProduct catalogProduct) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => ProductPortionSheet(
catalogProduct: catalogProduct,
onConfirm: (portionGrams) => _addToDiary(catalogProduct, portionGrams),
),
).then((_) {
if (mounted) setState(() => _scanning = true);
});
}
Future<void> _addToDiary(
CatalogProduct catalogProduct, double portionGrams) async {
final l10n = AppLocalizations.of(context)!;
try {
await ref.read(apiClientProvider).post('/diary', data: {
'product_id': catalogProduct.id,
'portion_g': portionGrams,
'meal_type': widget.mealType,
'date': widget.date,
'source': 'barcode',
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(catalogProduct.displayName)),
);
widget.onAdded();
Navigator.pop(context);
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.addFailed)),
);
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.scanBarcode)),
body: MobileScanner(
onDetect: _onBarcodeDetected,
),
);
}
}

View File

@@ -0,0 +1,32 @@
import '../../core/api/api_client.dart';
import '../../shared/models/product.dart';
/// Service for looking up catalog products by barcode or search query.
/// Used in the diary flow to log pre-packaged foods.
class FoodProductService {
const FoodProductService(this._client);
final ApiClient _client;
/// Fetches catalog product by barcode. Returns null when not found.
Future<CatalogProduct?> getByBarcode(String barcode) async {
try {
final data = await _client.get('/products/barcode/$barcode');
return CatalogProduct.fromJson(data);
} catch (_) {
return null;
}
}
/// Searches catalog products by name query.
Future<List<CatalogProduct>> searchProducts(String query) async {
if (query.isEmpty) return [];
final list = await _client.getList(
'/products/search',
params: {'q': query, 'limit': '10'},
);
return list
.map((e) => CatalogProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/product.dart';
/// Bottom sheet that shows catalog product details and lets the user
/// specify a portion weight before adding to diary.
class ProductPortionSheet extends ConsumerStatefulWidget {
const ProductPortionSheet({
super.key,
required this.catalogProduct,
required this.onConfirm,
});
final CatalogProduct catalogProduct;
final void Function(double portionGrams) onConfirm;
@override
ConsumerState<ProductPortionSheet> createState() =>
_ProductPortionSheetState();
}
class _ProductPortionSheetState extends ConsumerState<ProductPortionSheet> {
late final _weightController = TextEditingController(text: '100');
@override
void dispose() {
_weightController.dispose();
super.dispose();
}
void _confirm() {
final grams = double.tryParse(_weightController.text);
if (grams == null || grams <= 0) return;
widget.onConfirm(grams);
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final insets = MediaQuery.viewInsetsOf(context);
final catalogProduct = widget.catalogProduct;
return Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
catalogProduct.displayName,
style: theme.textTheme.titleMedium,
),
if (catalogProduct.categoryName != null)
Text(
catalogProduct.categoryName!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
if (catalogProduct.caloriesPer100g != null)
_NutritionRow(
label: l10n.perHundredG,
calories: catalogProduct.caloriesPer100g!,
protein: catalogProduct.proteinPer100g,
fat: catalogProduct.fatPer100g,
carbs: catalogProduct.carbsPer100g,
),
const SizedBox(height: 16),
TextField(
controller: _weightController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: l10n.portionWeightG,
border: const OutlineInputBorder(),
),
autofocus: true,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _confirm,
child: Text(l10n.addToJournal),
),
],
),
);
}
}
class _NutritionRow extends StatelessWidget {
const _NutritionRow({
required this.label,
required this.calories,
this.protein,
this.fat,
this.carbs,
});
final String label;
final double calories;
final double? protein;
final double? fat;
final double? carbs;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: theme.textTheme.bodySmall),
Text(
'${calories.toInt()} kcal'
'${protein != null ? ' · P ${protein!.toStringAsFixed(1)}g' : ''}'
'${fat != null ? ' · F ${fat!.toStringAsFixed(1)}g' : ''}'
'${carbs != null ? ' · C ${carbs!.toStringAsFixed(1)}g' : ''}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}

View File

@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/locale/unit_provider.dart'; import '../../core/locale/unit_provider.dart';
import '../../shared/models/ingredient_mapping.dart'; import '../../shared/models/product.dart';
import 'product_provider.dart'; import 'user_product_provider.dart';
class AddProductScreen extends ConsumerStatefulWidget { class AddProductScreen extends ConsumerStatefulWidget {
const AddProductScreen({super.key}); const AddProductScreen({super.key});
@@ -21,11 +21,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
String _unit = 'pcs'; String _unit = 'pcs';
String? _category; String? _category;
String? _mappingId; String? _primaryProductId;
bool _saving = false; bool _saving = false;
// Autocomplete state // Autocomplete state
List<IngredientMapping> _suggestions = []; List<CatalogProduct> _suggestions = [];
bool _searching = false; bool _searching = false;
Timer? _debounce; Timer? _debounce;
@@ -42,7 +42,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_debounce?.cancel(); _debounce?.cancel();
// Reset mapping if user edits the name after selecting a suggestion // Reset mapping if user edits the name after selecting a suggestion
setState(() { setState(() {
_mappingId = null; _primaryProductId = null;
}); });
if (value.trim().isEmpty) { if (value.trim().isEmpty) {
@@ -53,8 +53,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_debounce = Timer(const Duration(milliseconds: 300), () async { _debounce = Timer(const Duration(milliseconds: 300), () async {
setState(() => _searching = true); setState(() => _searching = true);
try { try {
final service = ref.read(productServiceProvider); final service = ref.read(userProductServiceProvider);
final results = await service.searchIngredients(value.trim()); final results = await service.searchProducts(value.trim());
if (mounted) setState(() => _suggestions = results); if (mounted) setState(() => _suggestions = results);
} finally { } finally {
if (mounted) setState(() => _searching = false); if (mounted) setState(() => _searching = false);
@@ -62,16 +62,16 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
}); });
} }
void _selectSuggestion(IngredientMapping mapping) { void _selectSuggestion(CatalogProduct catalogProduct) {
setState(() { setState(() {
_nameController.text = mapping.displayName; _nameController.text = catalogProduct.displayName;
_mappingId = mapping.id; _primaryProductId = catalogProduct.id;
_category = mapping.category; _category = catalogProduct.category;
if (mapping.defaultUnit != null) { if (catalogProduct.defaultUnit != null) {
_unit = mapping.defaultUnit!; _unit = catalogProduct.defaultUnit!;
} }
if (mapping.storageDays != null) { if (catalogProduct.storageDays != null) {
_daysController.text = mapping.storageDays.toString(); _daysController.text = catalogProduct.storageDays.toString();
} }
_suggestions = []; _suggestions = [];
}); });
@@ -91,13 +91,13 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
setState(() => _saving = true); setState(() => _saving = true);
try { try {
await ref.read(productsProvider.notifier).create( await ref.read(userProductsProvider.notifier).create(
name: name, name: name,
quantity: qty, quantity: qty,
unit: _unit, unit: _unit,
category: _category, category: _category,
storageDays: days, storageDays: days,
mappingId: _mappingId, primaryProductId: _primaryProductId,
); );
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} catch (e) { } catch (e) {

View File

@@ -1,65 +0,0 @@
import '../../core/api/api_client.dart';
import '../../shared/models/ingredient_mapping.dart';
import '../../shared/models/product.dart';
class ProductService {
const ProductService(this._client);
final ApiClient _client;
Future<List<Product>> getProducts() async {
final list = await _client.getList('/products');
return list.map((e) => Product.fromJson(e as Map<String, dynamic>)).toList();
}
Future<Product> createProduct({
required String name,
required double quantity,
required String unit,
String? category,
int storageDays = 7,
String? mappingId,
}) async {
final data = await _client.post('/products', data: {
'name': name,
'quantity': quantity,
'unit': unit,
if (category != null) 'category': category,
'storage_days': storageDays,
if (mappingId != null) 'mapping_id': mappingId,
});
return Product.fromJson(data);
}
Future<Product> updateProduct(
String id, {
String? name,
double? quantity,
String? unit,
String? category,
int? storageDays,
}) async {
final data = await _client.put('/products/$id', data: {
if (name != null) 'name': name,
if (quantity != null) 'quantity': quantity,
if (unit != null) 'unit': unit,
if (category != null) 'category': category,
if (storageDays != null) 'storage_days': storageDays,
});
return Product.fromJson(data);
}
Future<void> deleteProduct(String id) =>
_client.deleteVoid('/products/$id');
Future<List<IngredientMapping>> searchIngredients(String query) async {
if (query.isEmpty) return [];
final list = await _client.getList(
'/ingredients/search',
params: {'q': query, 'limit': '10'},
);
return list
.map((e) => IngredientMapping.fromJson(e as Map<String, dynamic>))
.toList();
}
}

View File

@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/locale/unit_provider.dart'; import '../../core/locale/unit_provider.dart';
import '../../shared/models/product.dart'; import '../../shared/models/user_product.dart';
import 'product_provider.dart'; import 'user_product_provider.dart';
void _showAddMenu(BuildContext context) { void _showAddMenu(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
@@ -40,7 +40,7 @@ class ProductsScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(productsProvider); final state = ref.watch(userProductsProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -48,7 +48,7 @@ class ProductsScreen extends ConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: () => ref.read(productsProvider.notifier).refresh(), onPressed: () => ref.read(userProductsProvider.notifier).refresh(),
), ),
], ],
), ),
@@ -60,7 +60,7 @@ class ProductsScreen extends ConsumerWidget {
body: state.when( body: state.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => _ErrorView( error: (err, _) => _ErrorView(
onRetry: () => ref.read(productsProvider.notifier).refresh(), onRetry: () => ref.read(userProductsProvider.notifier).refresh(),
), ),
data: (products) => products.isEmpty data: (products) => products.isEmpty
? _EmptyState( ? _EmptyState(
@@ -79,7 +79,7 @@ class ProductsScreen extends ConsumerWidget {
class _ProductList extends ConsumerWidget { class _ProductList extends ConsumerWidget {
const _ProductList({required this.products}); const _ProductList({required this.products});
final List<Product> products; final List<UserProduct> products;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -87,7 +87,7 @@ class _ProductList extends ConsumerWidget {
final rest = products.where((p) => !p.expiringSoon).toList(); final rest = products.where((p) => !p.expiringSoon).toList();
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(), onRefresh: () => ref.read(userProductsProvider.notifier).refresh(),
child: ListView( child: ListView(
padding: const EdgeInsets.only(bottom: 80), padding: const EdgeInsets.only(bottom: 80),
children: [ children: [
@@ -154,7 +154,7 @@ class _SectionHeader extends StatelessWidget {
class _ProductTile extends ConsumerWidget { class _ProductTile extends ConsumerWidget {
const _ProductTile({required this.product}); const _ProductTile({required this.product});
final Product product; final UserProduct product;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -194,7 +194,7 @@ class _ProductTile extends ConsumerWidget {
); );
}, },
onDismissed: (_) { onDismissed: (_) {
ref.read(productsProvider.notifier).delete(product.id); ref.read(userProductsProvider.notifier).delete(product.id);
}, },
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
@@ -274,7 +274,7 @@ class _ProductTile extends ConsumerWidget {
} }
} }
void _showEditSheet(BuildContext context, WidgetRef ref, Product product) { void _showEditSheet(BuildContext context, WidgetRef ref, UserProduct product) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -290,7 +290,7 @@ class _ProductTile extends ConsumerWidget {
class _EditProductSheet extends ConsumerStatefulWidget { class _EditProductSheet extends ConsumerStatefulWidget {
const _EditProductSheet({required this.product}); const _EditProductSheet({required this.product});
final Product product; final UserProduct product;
@override @override
ConsumerState<_EditProductSheet> createState() => _EditProductSheetState(); ConsumerState<_EditProductSheet> createState() => _EditProductSheetState();
@@ -382,7 +382,7 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> {
try { try {
final qty = double.tryParse(_qtyController.text); final qty = double.tryParse(_qtyController.text);
final days = int.tryParse(_daysController.text); final days = int.tryParse(_daysController.text);
await ref.read(productsProvider.notifier).update( await ref.read(userProductsProvider.notifier).update(
widget.product.id, widget.product.id,
quantity: qty, quantity: qty,
unit: _unit, unit: _unit,

View File

@@ -1,33 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart'; import '../../core/auth/auth_provider.dart';
import '../../shared/models/product.dart'; import '../../shared/models/user_product.dart';
import 'product_service.dart'; import 'user_product_service.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Providers // Providers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
final productServiceProvider = Provider<ProductService>((ref) { final userProductServiceProvider = Provider<UserProductService>((ref) {
return ProductService(ref.read(apiClientProvider)); return UserProductService(ref.read(apiClientProvider));
}); });
final productsProvider = final userProductsProvider =
StateNotifierProvider<ProductsNotifier, AsyncValue<List<Product>>>((ref) { StateNotifierProvider<UserProductsNotifier, AsyncValue<List<UserProduct>>>(
final service = ref.read(productServiceProvider); (ref) {
return ProductsNotifier(service); final service = ref.read(userProductServiceProvider);
return UserProductsNotifier(service);
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Notifier // Notifier
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> { class UserProductsNotifier
ProductsNotifier(this._service) : super(const AsyncValue.loading()) { extends StateNotifier<AsyncValue<List<UserProduct>>> {
UserProductsNotifier(this._service) : super(const AsyncValue.loading()) {
_load(); _load();
} }
final ProductService _service; final UserProductService _service;
Future<void> _load() async { Future<void> _load() async {
state = const AsyncValue.loading(); state = const AsyncValue.loading();
@@ -43,18 +45,18 @@ class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
required String unit, required String unit,
String? category, String? category,
int storageDays = 7, int storageDays = 7,
String? mappingId, String? primaryProductId,
}) async { }) async {
final p = await _service.createProduct( final userProduct = await _service.createProduct(
name: name, name: name,
quantity: quantity, quantity: quantity,
unit: unit, unit: unit,
category: category, category: category,
storageDays: storageDays, storageDays: storageDays,
mappingId: mappingId, primaryProductId: primaryProductId,
); );
state.whenData((products) { state.whenData((products) {
final updated = [...products, p] final updated = [...products, userProduct]
..sort((a, b) => a.expiresAt.compareTo(b.expiresAt)); ..sort((a, b) => a.expiresAt.compareTo(b.expiresAt));
state = AsyncValue.data(updated); state = AsyncValue.data(updated);
}); });
@@ -69,7 +71,7 @@ class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
String? category, String? category,
int? storageDays, int? storageDays,
}) async { }) async {
final p = await _service.updateProduct( final updated = await _service.updateProduct(
id, id,
name: name, name: name,
quantity: quantity, quantity: quantity,
@@ -78,9 +80,9 @@ class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
storageDays: storageDays, storageDays: storageDays,
); );
state.whenData((products) { state.whenData((products) {
final updated = products.map((e) => e.id == id ? p : e).toList() final updatedList = products.map((e) => e.id == id ? updated : e).toList()
..sort((a, b) => a.expiresAt.compareTo(b.expiresAt)); ..sort((a, b) => a.expiresAt.compareTo(b.expiresAt));
state = AsyncValue.data(updated); state = AsyncValue.data(updatedList);
}); });
} }
@@ -88,7 +90,8 @@ class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
Future<void> delete(String id) async { Future<void> delete(String id) async {
final previous = state; final previous = state;
state.whenData((products) { state.whenData((products) {
state = AsyncValue.data(products.where((p) => p.id != id).toList()); state =
AsyncValue.data(products.where((userProduct) => userProduct.id != id).toList());
}); });
try { try {
await _service.deleteProduct(id); await _service.deleteProduct(id);

View File

@@ -0,0 +1,76 @@
import '../../core/api/api_client.dart';
import '../../shared/models/product.dart';
import '../../shared/models/user_product.dart';
class UserProductService {
const UserProductService(this._client);
final ApiClient _client;
Future<List<UserProduct>> getProducts() async {
final list = await _client.getList('/user-products');
return list
.map((e) => UserProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<UserProduct> createProduct({
required String name,
required double quantity,
required String unit,
String? category,
int storageDays = 7,
String? primaryProductId,
}) async {
final data = await _client.post('/user-products', data: {
'name': name,
'quantity': quantity,
'unit': unit,
if (category != null) 'category': category,
'storage_days': storageDays,
if (primaryProductId != null) 'primary_product_id': primaryProductId,
});
return UserProduct.fromJson(data);
}
Future<UserProduct> updateProduct(
String id, {
String? name,
double? quantity,
String? unit,
String? category,
int? storageDays,
}) async {
final data = await _client.put('/user-products/$id', data: {
if (name != null) 'name': name,
if (quantity != null) 'quantity': quantity,
if (unit != null) 'unit': unit,
if (category != null) 'category': category,
if (storageDays != null) 'storage_days': storageDays,
});
return UserProduct.fromJson(data);
}
Future<void> deleteProduct(String id) =>
_client.deleteVoid('/user-products/$id');
Future<List<CatalogProduct>> searchProducts(String query) async {
if (query.isEmpty) return [];
final list = await _client.getList(
'/products/search',
params: {'q': query, 'limit': '10'},
);
return list
.map((e) => CatalogProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<CatalogProduct?> getByBarcode(String barcode) async {
try {
final data = await _client.get('/products/barcode/$barcode');
return CatalogProduct.fromJson(data);
} catch (_) {
return null;
}
}
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/locale/unit_provider.dart'; import '../../core/locale/unit_provider.dart';
import '../products/product_provider.dart'; import '../products/user_product_provider.dart';
import 'recognition_service.dart'; import 'recognition_service.dart';
/// Editable confirmation screen shown after receipt/products recognition. /// Editable confirmation screen shown after receipt/products recognition.
@@ -31,7 +31,7 @@ class _RecognitionConfirmScreenState
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
category: item.category, category: item.category,
mappingId: item.mappingId, primaryProductId: item.primaryProductId,
storageDays: item.storageDays, storageDays: item.storageDays,
confidence: item.confidence, confidence: item.confidence,
)) ))
@@ -83,13 +83,13 @@ class _RecognitionConfirmScreenState
setState(() => _saving = true); setState(() => _saving = true);
try { try {
for (final item in _items) { for (final item in _items) {
await ref.read(productsProvider.notifier).create( await ref.read(userProductsProvider.notifier).create(
name: item.name, name: item.name,
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unit: item.unit,
category: item.category, category: item.category,
storageDays: item.storageDays, storageDays: item.storageDays,
mappingId: item.mappingId, primaryProductId: item.primaryProductId,
); );
} }
if (mounted) { if (mounted) {
@@ -123,7 +123,7 @@ class _EditableItem {
double quantity; double quantity;
String unit; String unit;
final String category; final String category;
final String? mappingId; final String? primaryProductId;
final int storageDays; final int storageDays;
final double confidence; final double confidence;
@@ -132,7 +132,7 @@ class _EditableItem {
required this.quantity, required this.quantity,
required this.unit, required this.unit,
required this.category, required this.category,
this.mappingId, this.primaryProductId,
required this.storageDays, required this.storageDays,
required this.confidence, required this.confidence,
}); });

View File

@@ -22,7 +22,7 @@ class RecognizedItem {
String unit; String unit;
final String category; final String category;
final double confidence; final double confidence;
final String? mappingId; final String? primaryProductId;
final int storageDays; final int storageDays;
RecognizedItem({ RecognizedItem({
@@ -31,7 +31,7 @@ class RecognizedItem {
required this.unit, required this.unit,
required this.category, required this.category,
required this.confidence, required this.confidence,
this.mappingId, this.primaryProductId,
required this.storageDays, required this.storageDays,
}); });
@@ -42,7 +42,7 @@ class RecognizedItem {
unit: json['unit'] as String? ?? 'pcs', unit: json['unit'] as String? ?? 'pcs',
category: json['category'] as String? ?? 'other', category: json['category'] as String? ?? 'other',
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0, confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
mappingId: json['mapping_id'] as String?, primaryProductId: json['mapping_id'] as String?,
storageDays: json['storage_days'] as int? ?? 7, storageDays: json['storage_days'] as int? ?? 7,
); );
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "الموضع {position}", "queuePosition": "الموضع {position}",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "جارٍ المعالجة...", "processing": "جارٍ المعالجة...",
@@ -102,5 +104,11 @@
"photoReceipt": "تصوير الإيصال", "photoReceipt": "تصوير الإيصال",
"photoReceiptSubtitle": "التعرف على جميع المنتجات من الإيصال", "photoReceiptSubtitle": "التعرف على جميع المنتجات من الإيصال",
"photoProducts": "تصوير المنتجات", "photoProducts": "تصوير المنتجات",
"photoProductsSubtitle": "الثلاجة، الطاولة، الرف — حتى 3 صور" "photoProductsSubtitle": "الثلاجة، الطاولة، الرف — حتى 3 صور",
"addPackagedFood": "إضافة منتج معبأ",
"scanBarcode": "مسح الباركود",
"portionWeightG": "وزن الحصة (جم)",
"productNotFound": "المنتج غير موجود",
"enterManually": "أدخل يدوياً",
"perHundredG": "لكل 100 جم"
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "Position {position}", "queuePosition": "Position {position}",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "Verarbeitung...", "processing": "Verarbeitung...",
@@ -102,5 +104,11 @@
"photoReceipt": "Kassenbon fotografieren", "photoReceipt": "Kassenbon fotografieren",
"photoReceiptSubtitle": "Alle Produkte vom Kassenbon erkennen", "photoReceiptSubtitle": "Alle Produkte vom Kassenbon erkennen",
"photoProducts": "Produkte fotografieren", "photoProducts": "Produkte fotografieren",
"photoProductsSubtitle": "Kühlschrank, Tisch, Regal — bis zu 3 Fotos" "photoProductsSubtitle": "Kühlschrank, Tisch, Regal — bis zu 3 Fotos",
"addPackagedFood": "Verpacktes Lebensmittel hinzufügen",
"scanBarcode": "Barcode scannen",
"portionWeightG": "Portionsgewicht (g)",
"productNotFound": "Produkt nicht gefunden",
"enterManually": "Manuell eingeben",
"perHundredG": "pro 100 g"
} }

View File

@@ -102,5 +102,11 @@
"photoReceipt": "Photo of receipt", "photoReceipt": "Photo of receipt",
"photoReceiptSubtitle": "Recognize all items from a receipt", "photoReceiptSubtitle": "Recognize all items from a receipt",
"photoProducts": "Photo of products", "photoProducts": "Photo of products",
"photoProductsSubtitle": "Fridge, table, shelf — up to 3 photos" "photoProductsSubtitle": "Fridge, table, shelf — up to 3 photos",
"addPackagedFood": "Add packaged food",
"scanBarcode": "Scan barcode",
"portionWeightG": "Portion weight (g)",
"productNotFound": "Product not found",
"enterManually": "Enter manually",
"perHundredG": "per 100 g"
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "Posición {position}", "queuePosition": "Posición {position}",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "Procesando...", "processing": "Procesando...",
@@ -102,5 +104,11 @@
"photoReceipt": "Fotografiar recibo", "photoReceipt": "Fotografiar recibo",
"photoReceiptSubtitle": "Reconocemos todos los productos del recibo", "photoReceiptSubtitle": "Reconocemos todos los productos del recibo",
"photoProducts": "Fotografiar productos", "photoProducts": "Fotografiar productos",
"photoProductsSubtitle": "Nevera, mesa, estante — hasta 3 fotos" "photoProductsSubtitle": "Nevera, mesa, estante — hasta 3 fotos",
"addPackagedFood": "Agregar alimento envasado",
"scanBarcode": "Escanear código de barras",
"portionWeightG": "Peso de la porción (g)",
"productNotFound": "Producto no encontrado",
"enterManually": "Ingresar manualmente",
"perHundredG": "por 100 g"
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "Position {position}", "queuePosition": "Position {position}",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "Traitement...", "processing": "Traitement...",
@@ -102,5 +104,11 @@
"photoReceipt": "Photographier le ticket", "photoReceipt": "Photographier le ticket",
"photoReceiptSubtitle": "Reconnaissance de tous les produits du ticket", "photoReceiptSubtitle": "Reconnaissance de tous les produits du ticket",
"photoProducts": "Photographier les produits", "photoProducts": "Photographier les produits",
"photoProductsSubtitle": "Réfrigérateur, table, étagère — jusqu'à 3 photos" "photoProductsSubtitle": "Réfrigérateur, table, étagère — jusqu'à 3 photos",
"addPackagedFood": "Ajouter un aliment emballé",
"scanBarcode": "Scanner le code-barres",
"portionWeightG": "Poids de la portion (g)",
"productNotFound": "Produit introuvable",
"enterManually": "Saisir manuellement",
"perHundredG": "pour 100 g"
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "स्थिति {position}", "queuePosition": "स्थिति {position}",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "प्रसंस्करण हो रहा है...", "processing": "प्रसंस्करण हो रहा है...",
@@ -102,5 +104,11 @@
"photoReceipt": "रसीद की फ़ोटो", "photoReceipt": "रसीद की फ़ोटो",
"photoReceiptSubtitle": "रसीद से सभी उत्पाद पहचानें", "photoReceiptSubtitle": "रसीद से सभी उत्पाद पहचानें",
"photoProducts": "उत्पादों की फ़ोटो", "photoProducts": "उत्पादों की फ़ोटो",
"photoProductsSubtitle": "फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक" "photoProductsSubtitle": "फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक",
"addPackagedFood": "पैकेज्ड फूड जोड़ें",
"scanBarcode": "बारकोड स्कैन करें",
"portionWeightG": "हिस्से का वजन (ग्राम)",
"productNotFound": "उत्पाद नहीं मिला",
"enterManually": "मैन्युअल दर्ज करें",
"perHundredG": "प्रति 100 ग्राम"
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "Posizione {position}", "queuePosition": "Posizione {position}",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "Elaborazione...", "processing": "Elaborazione...",
@@ -102,5 +104,11 @@
"photoReceipt": "Fotografa scontrino", "photoReceipt": "Fotografa scontrino",
"photoReceiptSubtitle": "Riconosciamo tutti i prodotti dallo scontrino", "photoReceiptSubtitle": "Riconosciamo tutti i prodotti dallo scontrino",
"photoProducts": "Fotografa i prodotti", "photoProducts": "Fotografa i prodotti",
"photoProductsSubtitle": "Frigo, tavolo, scaffale — fino a 3 foto" "photoProductsSubtitle": "Frigo, tavolo, scaffale — fino a 3 foto",
"addPackagedFood": "Aggiungi alimento confezionato",
"scanBarcode": "Scansiona codice a barre",
"portionWeightG": "Peso della porzione (g)",
"productNotFound": "Prodotto non trovato",
"enterManually": "Inserisci manualmente",
"perHundredG": "per 100 g"
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "{position}番目", "queuePosition": "{position}番目",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "処理中...", "processing": "処理中...",
@@ -102,5 +104,11 @@
"photoReceipt": "レシートを撮影", "photoReceipt": "レシートを撮影",
"photoReceiptSubtitle": "レシートから全商品を認識", "photoReceiptSubtitle": "レシートから全商品を認識",
"photoProducts": "食品を撮影", "photoProducts": "食品を撮影",
"photoProductsSubtitle": "冷蔵庫・テーブル・棚 — 最大3枚" "photoProductsSubtitle": "冷蔵庫・テーブル・棚 — 最大3枚",
"addPackagedFood": "パッケージ食品を追加",
"scanBarcode": "バーコードをスキャン",
"portionWeightG": "1食分の重さg",
"productNotFound": "商品が見つかりません",
"enterManually": "手動で入力",
"perHundredG": "100gあたり"
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "{position}번째", "queuePosition": "{position}번째",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "처리 중...", "processing": "처리 중...",
@@ -102,5 +104,11 @@
"photoReceipt": "영수증 촬영", "photoReceipt": "영수증 촬영",
"photoReceiptSubtitle": "영수증의 모든 상품 인식", "photoReceiptSubtitle": "영수증의 모든 상품 인식",
"photoProducts": "식품 촬영", "photoProducts": "식품 촬영",
"photoProductsSubtitle": "냉장고, 테이블, 선반 — 최대 3장" "photoProductsSubtitle": "냉장고, 테이블, 선반 — 최대 3장",
"addPackagedFood": "포장 식품 추가",
"scanBarcode": "바코드 스캔",
"portionWeightG": "1회 제공량 (g)",
"productNotFound": "제품을 찾을 수 없습니다",
"enterManually": "직접 입력",
"perHundredG": "100g당"
} }

View File

@@ -705,6 +705,42 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Fridge, table, shelf — up to 3 photos'** /// **'Fridge, table, shelf — up to 3 photos'**
String get photoProductsSubtitle; String get photoProductsSubtitle;
/// No description provided for @addPackagedFood.
///
/// In en, this message translates to:
/// **'Add packaged food'**
String get addPackagedFood;
/// No description provided for @scanBarcode.
///
/// In en, this message translates to:
/// **'Scan barcode'**
String get scanBarcode;
/// No description provided for @portionWeightG.
///
/// In en, this message translates to:
/// **'Portion weight (g)'**
String get portionWeightG;
/// No description provided for @productNotFound.
///
/// In en, this message translates to:
/// **'Product not found'**
String get productNotFound;
/// No description provided for @enterManually.
///
/// In en, this message translates to:
/// **'Enter manually'**
String get enterManually;
/// No description provided for @perHundredG.
///
/// In en, this message translates to:
/// **'per 100 g'**
String get perHundredG;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@@ -304,4 +304,22 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get photoProductsSubtitle => 'الثلاجة، الطاولة، الرف — حتى 3 صور'; String get photoProductsSubtitle => 'الثلاجة، الطاولة، الرف — حتى 3 صور';
@override
String get addPackagedFood => 'إضافة منتج معبأ';
@override
String get scanBarcode => 'مسح الباركود';
@override
String get portionWeightG => 'وزن الحصة (جم)';
@override
String get productNotFound => 'المنتج غير موجود';
@override
String get enterManually => 'أدخل يدوياً';
@override
String get perHundredG => 'لكل 100 جم';
} }

View File

@@ -306,4 +306,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get photoProductsSubtitle => String get photoProductsSubtitle =>
'Kühlschrank, Tisch, Regal — bis zu 3 Fotos'; 'Kühlschrank, Tisch, Regal — bis zu 3 Fotos';
@override
String get addPackagedFood => 'Verpacktes Lebensmittel hinzufügen';
@override
String get scanBarcode => 'Barcode scannen';
@override
String get portionWeightG => 'Portionsgewicht (g)';
@override
String get productNotFound => 'Produkt nicht gefunden';
@override
String get enterManually => 'Manuell eingeben';
@override
String get perHundredG => 'pro 100 g';
} }

View File

@@ -304,4 +304,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get photoProductsSubtitle => 'Fridge, table, shelf — up to 3 photos'; String get photoProductsSubtitle => 'Fridge, table, shelf — up to 3 photos';
@override
String get addPackagedFood => 'Add packaged food';
@override
String get scanBarcode => 'Scan barcode';
@override
String get portionWeightG => 'Portion weight (g)';
@override
String get productNotFound => 'Product not found';
@override
String get enterManually => 'Enter manually';
@override
String get perHundredG => 'per 100 g';
} }

View File

@@ -306,4 +306,22 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get photoProductsSubtitle => 'Nevera, mesa, estante — hasta 3 fotos'; String get photoProductsSubtitle => 'Nevera, mesa, estante — hasta 3 fotos';
@override
String get addPackagedFood => 'Agregar alimento envasado';
@override
String get scanBarcode => 'Escanear código de barras';
@override
String get portionWeightG => 'Peso de la porción (g)';
@override
String get productNotFound => 'Producto no encontrado';
@override
String get enterManually => 'Ingresar manualmente';
@override
String get perHundredG => 'por 100 g';
} }

View File

@@ -307,4 +307,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get photoProductsSubtitle => String get photoProductsSubtitle =>
'Réfrigérateur, table, étagère — jusqu\'à 3 photos'; 'Réfrigérateur, table, étagère — jusqu\'à 3 photos';
@override
String get addPackagedFood => 'Ajouter un aliment emballé';
@override
String get scanBarcode => 'Scanner le code-barres';
@override
String get portionWeightG => 'Poids de la portion (g)';
@override
String get productNotFound => 'Produit introuvable';
@override
String get enterManually => 'Saisir manuellement';
@override
String get perHundredG => 'pour 100 g';
} }

View File

@@ -305,4 +305,22 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get photoProductsSubtitle => 'फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक'; String get photoProductsSubtitle => 'फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक';
@override
String get addPackagedFood => 'पैकेज्ड फूड जोड़ें';
@override
String get scanBarcode => 'बारकोड स्कैन करें';
@override
String get portionWeightG => 'हिस्से का वजन (ग्राम)';
@override
String get productNotFound => 'उत्पाद नहीं मिला';
@override
String get enterManually => 'मैन्युअल दर्ज करें';
@override
String get perHundredG => 'प्रति 100 ग्राम';
} }

View File

@@ -306,4 +306,22 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get photoProductsSubtitle => 'Frigo, tavolo, scaffale — fino a 3 foto'; String get photoProductsSubtitle => 'Frigo, tavolo, scaffale — fino a 3 foto';
@override
String get addPackagedFood => 'Aggiungi alimento confezionato';
@override
String get scanBarcode => 'Scansiona codice a barre';
@override
String get portionWeightG => 'Peso della porzione (g)';
@override
String get productNotFound => 'Prodotto non trovato';
@override
String get enterManually => 'Inserisci manualmente';
@override
String get perHundredG => 'per 100 g';
} }

View File

@@ -303,4 +303,22 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get photoProductsSubtitle => '冷蔵庫・テーブル・棚 — 最大3枚'; String get photoProductsSubtitle => '冷蔵庫・テーブル・棚 — 最大3枚';
@override
String get addPackagedFood => 'パッケージ食品を追加';
@override
String get scanBarcode => 'バーコードをスキャン';
@override
String get portionWeightG => '1食分の重さg';
@override
String get productNotFound => '商品が見つかりません';
@override
String get enterManually => '手動で入力';
@override
String get perHundredG => '100gあたり';
} }

View File

@@ -303,4 +303,22 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get photoProductsSubtitle => '냉장고, 테이블, 선반 — 최대 3장'; String get photoProductsSubtitle => '냉장고, 테이블, 선반 — 최대 3장';
@override
String get addPackagedFood => '포장 식품 추가';
@override
String get scanBarcode => '바코드 스캔';
@override
String get portionWeightG => '1회 제공량 (g)';
@override
String get productNotFound => '제품을 찾을 수 없습니다';
@override
String get enterManually => '직접 입력';
@override
String get perHundredG => '100g당';
} }

View File

@@ -306,4 +306,22 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get photoProductsSubtitle => String get photoProductsSubtitle =>
'Geladeira, mesa, prateleira — até 3 fotos'; 'Geladeira, mesa, prateleira — até 3 fotos';
@override
String get addPackagedFood => 'Adicionar alimento embalado';
@override
String get scanBarcode => 'Escanear código de barras';
@override
String get portionWeightG => 'Peso da porção (g)';
@override
String get productNotFound => 'Produto não encontrado';
@override
String get enterManually => 'Inserir manualmente';
@override
String get perHundredG => 'por 100 g';
} }

View File

@@ -304,4 +304,22 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get photoProductsSubtitle => 'Холодильник, стол, полка — до 3 фото'; String get photoProductsSubtitle => 'Холодильник, стол, полка — до 3 фото';
@override
String get addPackagedFood => 'Добавить готовый продукт';
@override
String get scanBarcode => 'Сканировать штрихкод';
@override
String get portionWeightG => 'Вес порции (г)';
@override
String get productNotFound => 'Продукт не найден';
@override
String get enterManually => 'Ввести вручную';
@override
String get perHundredG => 'на 100 г';
} }

View File

@@ -303,4 +303,22 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get photoProductsSubtitle => '冰箱、桌子、货架 — 最多3张照片'; String get photoProductsSubtitle => '冰箱、桌子、货架 — 最多3张照片';
@override
String get addPackagedFood => '添加包装食品';
@override
String get scanBarcode => '扫描条形码';
@override
String get portionWeightG => '份量(克)';
@override
String get productNotFound => '未找到产品';
@override
String get enterManually => '手动输入';
@override
String get perHundredG => '每100克';
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "Posição {position}", "queuePosition": "Posição {position}",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "Processando...", "processing": "Processando...",
@@ -102,5 +104,11 @@
"photoReceipt": "Fotografar recibo", "photoReceipt": "Fotografar recibo",
"photoReceiptSubtitle": "Reconhecemos todos os produtos do recibo", "photoReceiptSubtitle": "Reconhecemos todos os produtos do recibo",
"photoProducts": "Fotografar produtos", "photoProducts": "Fotografar produtos",
"photoProductsSubtitle": "Geladeira, mesa, prateleira — até 3 fotos" "photoProductsSubtitle": "Geladeira, mesa, prateleira — até 3 fotos",
"addPackagedFood": "Adicionar alimento embalado",
"scanBarcode": "Escanear código de barras",
"portionWeightG": "Peso da porção (g)",
"productNotFound": "Produto não encontrado",
"enterManually": "Inserir manualmente",
"perHundredG": "por 100 g"
} }

View File

@@ -102,5 +102,11 @@
"photoReceipt": "Сфотографировать чек", "photoReceipt": "Сфотографировать чек",
"photoReceiptSubtitle": "Распознаем все продукты из чека", "photoReceiptSubtitle": "Распознаем все продукты из чека",
"photoProducts": "Сфотографировать продукты", "photoProducts": "Сфотографировать продукты",
"photoProductsSubtitle": "Холодильник, стол, полка — до 3 фото" "photoProductsSubtitle": "Холодильник, стол, полка — до 3 фото",
"addPackagedFood": "Добавить готовый продукт",
"scanBarcode": "Сканировать штрихкод",
"portionWeightG": "Вес порции (г)",
"productNotFound": "Продукт не найден",
"enterManually": "Ввести вручную",
"perHundredG": "на 100 г"
} }

View File

@@ -28,7 +28,9 @@
"queuePosition": "位置 {position}", "queuePosition": "位置 {position}",
"@queuePosition": { "@queuePosition": {
"placeholders": { "placeholders": {
"position": { "type": "int" } "position": {
"type": "int"
}
} }
}, },
"processing": "处理中...", "processing": "处理中...",
@@ -102,5 +104,11 @@
"photoReceipt": "拍摄收据", "photoReceipt": "拍摄收据",
"photoReceiptSubtitle": "识别收据中的所有商品", "photoReceiptSubtitle": "识别收据中的所有商品",
"photoProducts": "拍摄食品", "photoProducts": "拍摄食品",
"photoProductsSubtitle": "冰箱、桌子、货架 — 最多3张照片" "photoProductsSubtitle": "冰箱、桌子、货架 — 最多3张照片",
"addPackagedFood": "添加包装食品",
"scanBarcode": "扫描条形码",
"portionWeightG": "份量(克)",
"productNotFound": "未找到产品",
"enterManually": "手动输入",
"perHundredG": "每100克"
} }

View File

@@ -1,34 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'ingredient_mapping.g.dart';
@JsonSerializable()
class IngredientMapping {
final String id;
@JsonKey(name: 'canonical_name')
final String canonicalName;
@JsonKey(name: 'category_name')
final String? categoryName;
final String? category;
@JsonKey(name: 'default_unit')
final String? defaultUnit;
@JsonKey(name: 'storage_days')
final int? storageDays;
const IngredientMapping({
required this.id,
required this.canonicalName,
this.categoryName,
this.category,
this.defaultUnit,
this.storageDays,
});
/// Display name is the server-resolved canonical name (language-aware from backend).
String get displayName => canonicalName;
factory IngredientMapping.fromJson(Map<String, dynamic> json) =>
_$IngredientMappingFromJson(json);
Map<String, dynamic> toJson() => _$IngredientMappingToJson(this);
}

View File

@@ -1,27 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ingredient_mapping.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
IngredientMapping _$IngredientMappingFromJson(Map<String, dynamic> json) =>
IngredientMapping(
id: json['id'] as String,
canonicalName: json['canonical_name'] as String,
categoryName: json['category_name'] as String?,
category: json['category'] as String?,
defaultUnit: json['default_unit'] as String?,
storageDays: (json['storage_days'] as num?)?.toInt(),
);
Map<String, dynamic> _$IngredientMappingToJson(IngredientMapping instance) =>
<String, dynamic>{
'id': instance.id,
'canonical_name': instance.canonicalName,
'category_name': instance.categoryName,
'category': instance.category,
'default_unit': instance.defaultUnit,
'storage_days': instance.storageDays,
};

View File

@@ -2,45 +2,48 @@ import 'package:json_annotation/json_annotation.dart';
part 'product.g.dart'; part 'product.g.dart';
/// Catalog product (shared nutrition database entry).
@JsonSerializable() @JsonSerializable()
class Product { class CatalogProduct {
final String id; final String id;
@JsonKey(name: 'user_id') @JsonKey(name: 'canonical_name')
final String userId; final String canonicalName;
@JsonKey(name: 'mapping_id') @JsonKey(name: 'category_name')
final String? mappingId; final String? categoryName;
final String name;
final double quantity;
final String unit;
final String? category; final String? category;
@JsonKey(name: 'default_unit')
final String? defaultUnit;
@JsonKey(name: 'storage_days') @JsonKey(name: 'storage_days')
final int storageDays; final int? storageDays;
@JsonKey(name: 'added_at') final String? barcode;
final DateTime addedAt; @JsonKey(name: 'calories_per_100g')
@JsonKey(name: 'expires_at') final double? caloriesPer100g;
final DateTime expiresAt; @JsonKey(name: 'protein_per_100g')
@JsonKey(name: 'days_left') final double? proteinPer100g;
final int daysLeft; @JsonKey(name: 'fat_per_100g')
@JsonKey(name: 'expiring_soon') final double? fatPer100g;
final bool expiringSoon; @JsonKey(name: 'carbs_per_100g')
final double? carbsPer100g;
const Product({ const CatalogProduct({
required this.id, required this.id,
required this.userId, required this.canonicalName,
this.mappingId, this.categoryName,
required this.name,
required this.quantity,
required this.unit,
this.category, this.category,
required this.storageDays, this.defaultUnit,
required this.addedAt, this.storageDays,
required this.expiresAt, this.barcode,
required this.daysLeft, this.caloriesPer100g,
required this.expiringSoon, this.proteinPer100g,
this.fatPer100g,
this.carbsPer100g,
}); });
factory Product.fromJson(Map<String, dynamic> json) => /// Display name is the server-resolved canonical name (language-aware from backend).
_$ProductFromJson(json); String get displayName => canonicalName;
Map<String, dynamic> toJson() => _$ProductToJson(this); factory CatalogProduct.fromJson(Map<String, dynamic> json) =>
_$CatalogProductFromJson(json);
Map<String, dynamic> toJson() => _$CatalogProductToJson(this);
} }

View File

@@ -6,32 +6,32 @@ part of 'product.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
Product _$ProductFromJson(Map<String, dynamic> json) => Product( CatalogProduct _$CatalogProductFromJson(Map<String, dynamic> json) =>
id: json['id'] as String, CatalogProduct(
userId: json['user_id'] as String, id: json['id'] as String,
mappingId: json['mapping_id'] as String?, canonicalName: json['canonical_name'] as String,
name: json['name'] as String, categoryName: json['category_name'] as String?,
quantity: (json['quantity'] as num).toDouble(), category: json['category'] as String?,
unit: json['unit'] as String, defaultUnit: json['default_unit'] as String?,
category: json['category'] as String?, storageDays: (json['storage_days'] as num?)?.toInt(),
storageDays: (json['storage_days'] as num).toInt(), barcode: json['barcode'] as String?,
addedAt: DateTime.parse(json['added_at'] as String), caloriesPer100g: (json['calories_per_100g'] as num?)?.toDouble(),
expiresAt: DateTime.parse(json['expires_at'] as String), proteinPer100g: (json['protein_per_100g'] as num?)?.toDouble(),
daysLeft: (json['days_left'] as num).toInt(), fatPer100g: (json['fat_per_100g'] as num?)?.toDouble(),
expiringSoon: json['expiring_soon'] as bool, carbsPer100g: (json['carbs_per_100g'] as num?)?.toDouble(),
); );
Map<String, dynamic> _$ProductToJson(Product instance) => <String, dynamic>{ Map<String, dynamic> _$CatalogProductToJson(CatalogProduct instance) =>
'id': instance.id, <String, dynamic>{
'user_id': instance.userId, 'id': instance.id,
'mapping_id': instance.mappingId, 'canonical_name': instance.canonicalName,
'name': instance.name, 'category_name': instance.categoryName,
'quantity': instance.quantity, 'category': instance.category,
'unit': instance.unit, 'default_unit': instance.defaultUnit,
'category': instance.category, 'storage_days': instance.storageDays,
'storage_days': instance.storageDays, 'barcode': instance.barcode,
'added_at': instance.addedAt.toIso8601String(), 'calories_per_100g': instance.caloriesPer100g,
'expires_at': instance.expiresAt.toIso8601String(), 'protein_per_100g': instance.proteinPer100g,
'days_left': instance.daysLeft, 'fat_per_100g': instance.fatPer100g,
'expiring_soon': instance.expiringSoon, 'carbs_per_100g': instance.carbsPer100g,
}; };

View File

@@ -0,0 +1,46 @@
import 'package:json_annotation/json_annotation.dart';
part 'user_product.g.dart';
@JsonSerializable()
class UserProduct {
final String id;
@JsonKey(name: 'user_id')
final String userId;
@JsonKey(name: 'primary_product_id')
final String? primaryProductId;
final String name;
final double quantity;
final String unit;
final String? category;
@JsonKey(name: 'storage_days')
final int storageDays;
@JsonKey(name: 'added_at')
final DateTime addedAt;
@JsonKey(name: 'expires_at')
final DateTime expiresAt;
@JsonKey(name: 'days_left')
final int daysLeft;
@JsonKey(name: 'expiring_soon')
final bool expiringSoon;
const UserProduct({
required this.id,
required this.userId,
this.primaryProductId,
required this.name,
required this.quantity,
required this.unit,
this.category,
required this.storageDays,
required this.addedAt,
required this.expiresAt,
required this.daysLeft,
required this.expiringSoon,
});
factory UserProduct.fromJson(Map<String, dynamic> json) =>
_$UserProductFromJson(json);
Map<String, dynamic> toJson() => _$UserProductToJson(this);
}

View File

@@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_product.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UserProduct _$UserProductFromJson(Map<String, dynamic> json) => UserProduct(
id: json['id'] as String,
userId: json['user_id'] as String,
primaryProductId: json['primary_product_id'] as String?,
name: json['name'] as String,
quantity: (json['quantity'] as num).toDouble(),
unit: json['unit'] as String,
category: json['category'] as String?,
storageDays: (json['storage_days'] as num).toInt(),
addedAt: DateTime.parse(json['added_at'] as String),
expiresAt: DateTime.parse(json['expires_at'] as String),
daysLeft: (json['days_left'] as num).toInt(),
expiringSoon: json['expiring_soon'] as bool,
);
Map<String, dynamic> _$UserProductToJson(UserProduct instance) =>
<String, dynamic>{
'id': instance.id,
'user_id': instance.userId,
'primary_product_id': instance.primaryProductId,
'name': instance.name,
'quantity': instance.quantity,
'unit': instance.unit,
'category': instance.category,
'storage_days': instance.storageDays,
'added_at': instance.addedAt.toIso8601String(),
'expires_at': instance.expiresAt.toIso8601String(),
'days_left': instance.daysLeft,
'expiring_soon': instance.expiringSoon,
};

View File

@@ -717,6 +717,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
url: "https://pub.dev"
source: hosted
version: "6.0.11"
mockito: mockito:
dependency: "direct dev" dependency: "direct dev"
description: description:

View File

@@ -43,6 +43,7 @@ dependencies:
# Camera / gallery # Camera / gallery
image_picker: ^1.1.0 image_picker: ^1.1.0
mobile_scanner: ^6.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: