diff --git a/backend/cmd/importoff/main.go b/backend/cmd/importoff/main.go new file mode 100644 index 0000000..f6a3dd6 --- /dev/null +++ b/backend/cmd/importoff/main.go @@ -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 +} diff --git a/backend/cmd/server/init.go b/backend/cmd/server/init.go index 098f997..859b693 100644 --- a/backend/cmd/server/init.go +++ b/backend/cmd/server/init.go @@ -6,7 +6,6 @@ import ( "github.com/food-ai/backend/internal/domain/diary" "github.com/food-ai/backend/internal/domain/dish" "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/product" "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/savedrecipe" "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/jackc/pgx/v5/pgxpool" ) @@ -37,14 +37,15 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) { openaiClient := newOpenAIClient(mainGeminiAPIKey) mainPexelsAPIKey := newPexelsAPIKey(appConfig) pexelsClient := newPexelsClient(mainPexelsAPIKey) - productRepository := product.NewRepository(pool) - recommendationHandler := recommendation.NewHandler(openaiClient, pexelsClient, userRepository, productRepository) + userProductRepository := userproduct.NewRepository(pool) + recommendationHandler := recommendation.NewHandler(openaiClient, pexelsClient, userRepository, userProductRepository) dishRepository := dish.NewRepository(pool) savedrecipeRepository := savedrecipe.NewRepository(pool, dishRepository) savedrecipeHandler := savedrecipe.NewHandler(savedrecipeRepository) - ingredientRepository := ingredient.NewRepository(pool) - ingredientHandler := ingredient.NewHandler(ingredientRepository) - productHandler := product.NewHandler(productRepository) + productRepository := product.NewRepository(pool) + openFoodFactsClient := product.NewOpenFoodFacts() + productHandler := product.NewHandler(productRepository, openFoodFactsClient) + userProductHandler := userproduct.NewHandler(userProductRepository) // Kafka producer kafkaProducer, kafkaProducerError := newKafkaProducer(appConfig) @@ -55,10 +56,10 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) { // Recognition pipeline jobRepository := recognition.NewJobRepository(pool) 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) - menuHandler := menu.NewHandler(menuRepository, openaiClient, pexelsClient, userRepository, productRepository, dishRepository) + menuHandler := menu.NewHandler(menuRepository, openaiClient, pexelsClient, userRepository, userProductRepository, dishRepository) diaryRepository := diary.NewRepository(pool) diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository) homeHandler := home.NewHandler(pool) @@ -77,8 +78,8 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) { userHandler, recommendationHandler, savedrecipeHandler, - ingredientHandler, productHandler, + userProductHandler, recognitionHandler, menuHandler, diaryHandler, diff --git a/backend/cmd/server/providers.go b/backend/cmd/server/providers.go index bb95e8d..3eb79a3 100644 --- a/backend/cmd/server/providers.go +++ b/backend/cmd/server/providers.go @@ -11,7 +11,6 @@ import ( "github.com/food-ai/backend/internal/adapters/kafka" "github.com/food-ai/backend/internal/adapters/openai" "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/infra/middleware" "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/units" "github.com/food-ai/backend/internal/domain/user" + "github.com/food-ai/backend/internal/domain/userproduct" "github.com/jackc/pgx/v5/pgxpool" ) @@ -125,8 +125,8 @@ func newRouter( userHandler *user.Handler, recommendationHandler *recommendation.Handler, savedRecipeHandler *savedrecipe.Handler, - ingredientHandler *ingredient.Handler, productHandler *product.Handler, + userProductHandler *userproduct.Handler, recognitionHandler *recognition.Handler, menuHandler *menu.Handler, diaryHandler *diary.Handler, @@ -145,8 +145,8 @@ func newRouter( userHandler, recommendationHandler, savedRecipeHandler, - ingredientHandler, productHandler, + userProductHandler, recognitionHandler, menuHandler, diaryHandler, @@ -204,12 +204,12 @@ func newKafkaProducer(appConfig *config.Config) (*kafka.Producer, error) { var _ middleware.AccessTokenValidator = (*jwtAdapter)(nil) var _ menu.PhotoSearcher = (*pexels.Client)(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 _ recommendation.PhotoSearcher = (*pexels.Client)(nil) var _ recommendation.UserLoader = (*user.Repository)(nil) -var _ recommendation.ProductLister = (*product.Repository)(nil) -var _ recognition.IngredientRepository = (*ingredient.Repository)(nil) +var _ recommendation.ProductLister = (*userproduct.Repository)(nil) +var _ recognition.ProductRepository = (*product.Repository)(nil) var _ recognition.KafkaPublisher = (*kafka.Producer)(nil) var _ recognition.JobRepository = (*recognition.PostgresJobRepository)(nil) var _ user.UserRepository = (*user.Repository)(nil) diff --git a/backend/internal/domain/diary/entity.go b/backend/internal/domain/diary/entity.go index 213fccc..aa7f970 100644 --- a/backend/internal/domain/diary/entity.go +++ b/backend/internal/domain/diary/entity.go @@ -7,14 +7,15 @@ type Entry struct { ID string `json:"id"` Date string `json:"date"` // YYYY-MM-DD 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"` - 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"` FatG *float64 `json:"fat_g,omitempty"` CarbsG *float64 `json:"carbs_g,omitempty"` 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"` PortionG *float64 `json:"portion_g,omitempty"` JobID *string `json:"job_id,omitempty"` @@ -23,13 +24,14 @@ type Entry struct { // CreateRequest is the body for POST /diary. type CreateRequest struct { - Date string `json:"date"` - MealType string `json:"meal_type"` - Name string `json:"name"` // input-only; used if DishID is nil - Portions float64 `json:"portions"` - Source string `json:"source"` - DishID *string `json:"dish_id"` - RecipeID *string `json:"recipe_id"` - PortionG *float64 `json:"portion_g"` - JobID *string `json:"job_id"` + Date string `json:"date"` + MealType string `json:"meal_type"` + Name string `json:"name"` // input-only; used if DishID is nil and ProductID is nil + Portions float64 `json:"portions"` + Source string `json:"source"` + DishID *string `json:"dish_id"` + ProductID *string `json:"product_id"` + RecipeID *string `json:"recipe_id"` + PortionG *float64 `json:"portion_g"` + JobID *string `json:"job_id"` } diff --git a/backend/internal/domain/diary/handler.go b/backend/internal/domain/diary/handler.go index e488730..cbe2939 100644 --- a/backend/internal/domain/diary/handler.go +++ b/backend/internal/domain/diary/handler.go @@ -82,29 +82,32 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "date and meal_type are required") return } - if req.DishID == nil && req.Name == "" { - writeError(w, http.StatusBadRequest, "dish_id or name is required") + if req.DishID == nil && req.ProductID == nil && req.Name == "" { + writeError(w, http.StatusBadRequest, "dish_id, product_id, or name is required") return } - if req.DishID == nil { - dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name) - if resolveError != nil { - slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError) - writeError(w, http.StatusInternalServerError, "failed to resolve dish") - return + // Product-based entry: skip dish/recipe resolution entirely. + if req.ProductID == nil { + if req.DishID == nil { + dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name) + if resolveError != nil { + 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 { - recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0) - if recipeError != nil { - slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError) - writeError(w, http.StatusInternalServerError, "failed to resolve recipe") - return + if req.RecipeID == nil { + recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0) + if recipeError != nil { + slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError) + writeError(w, http.StatusInternalServerError, "failed to resolve recipe") + return + } + req.RecipeID = &recipeID } - req.RecipeID = &recipeID } entry, createError := h.repo.Create(r.Context(), userID, req) diff --git a/backend/internal/domain/diary/repository.go b/backend/internal/domain/diary/repository.go index fd50d8d..3d96ac2 100644 --- a/backend/internal/domain/diary/repository.go +++ b/backend/internal/domain/diary/repository.go @@ -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). -// 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) { lang := locale.FromContext(ctx) rows, err := r.pool.Query(ctx, ` SELECT md.id, md.date::text, md.meal_type, md.portions, - md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, - COALESCE(dt.name, d.name) AS dish_name, - r.calories_per_serving * md.portions, - r.protein_per_serving * md.portions, - r.fat_per_serving * md.portions, - r.carbs_per_serving * md.portions + md.source, md.dish_id::text, md.product_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, + COALESCE(dt.name, d.name, p.canonical_name) AS entry_name, + COALESCE( + r.calories_per_serving * md.portions, + p.calories_per_100g * md.portion_g / 100 + ), + COALESCE( + r.protein_per_serving * md.portions, + p.protein_per_100g * md.portion_g / 100 + ), + COALESCE( + r.fat_per_serving * md.portions, + p.fat_per_100g * md.portion_g / 100 + ), + COALESCE( + r.carbs_per_serving * md.portions, + p.carbs_per_100g * md.portion_g / 100 + ) FROM meal_diary md - 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 recipes r ON r.id = md.recipe_id + LEFT JOIN products p ON p.id = md.product_id WHERE md.user_id = $1 AND md.date = $2::date ORDER BY md.created_at ASC`, userID, date, lang) if err != nil { @@ -72,10 +85,10 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques var entryID string insertError := r.pool.QueryRow(ctx, ` - INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g, job_id) - VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, product_id, recipe_id, portion_g, job_id) + VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, - userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG, req.JobID, + userID, req.Date, req.MealType, portions, source, req.DishID, req.ProductID, req.RecipeID, req.PortionG, req.JobID, ).Scan(&entryID) if insertError != nil { return nil, fmt.Errorf("insert diary entry: %w", insertError) @@ -84,16 +97,29 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques row := r.pool.QueryRow(ctx, ` SELECT 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, - COALESCE(dt.name, d.name) AS dish_name, - r.calories_per_serving * md.portions, - r.protein_per_serving * md.portions, - r.fat_per_serving * md.portions, - r.carbs_per_serving * md.portions + md.source, md.dish_id::text, md.product_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, + COALESCE(dt.name, d.name, p.canonical_name) AS entry_name, + COALESCE( + r.calories_per_serving * md.portions, + p.calories_per_100g * md.portion_g / 100 + ), + COALESCE( + r.protein_per_serving * md.portions, + p.protein_per_100g * md.portion_g / 100 + ), + COALESCE( + r.fat_per_serving * md.portions, + p.fat_per_100g * md.portion_g / 100 + ), + COALESCE( + r.carbs_per_serving * md.portions, + p.carbs_per_100g * md.portion_g / 100 + ) FROM meal_diary md - 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 recipes r ON r.id = md.recipe_id + LEFT JOIN products p ON p.id = md.product_id WHERE md.id = $1`, entryID, lang) return scanEntry(row) } @@ -121,7 +147,7 @@ func scanEntry(s scannable) (*Entry, error) { var entry Entry scanError := s.Scan( &entry.ID, &entry.Date, &entry.MealType, &entry.Portions, - &entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.JobID, &entry.CreatedAt, + &entry.Source, &entry.DishID, &entry.ProductID, &entry.RecipeID, &entry.PortionG, &entry.JobID, &entry.CreatedAt, &entry.Name, &entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG, ) diff --git a/backend/internal/domain/home/handler.go b/backend/internal/domain/home/handler.go index a068cb8..e317f66 100644 --- a/backend/internal/domain/home/handler.go +++ b/backend/internal/domain/home/handler.go @@ -76,12 +76,21 @@ func (h *Handler) getDailyGoal(ctx context.Context, userID string) int { } // 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 { var total float64 _ = h.pool.QueryRow(ctx, - `SELECT COALESCE(SUM(calories * portions), 0) - FROM meal_diary - WHERE user_id = $1 AND date::text = $2`, + `SELECT COALESCE(SUM( + COALESCE( + 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, ).Scan(&total) return total @@ -153,7 +162,7 @@ func (h *Handler) getExpiringSoon(ctx context.Context, userID string) []Expiring WITH p AS ( SELECT name, quantity, unit, (added_at + storage_days * INTERVAL '1 day') AS expires_at - FROM products + FROM user_products WHERE user_id = $1 ) SELECT name, quantity, unit, diff --git a/backend/internal/domain/ingredient/entity.go b/backend/internal/domain/ingredient/entity.go deleted file mode 100644 index 2f867e2..0000000 --- a/backend/internal/domain/ingredient/entity.go +++ /dev/null @@ -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"` -} diff --git a/backend/internal/domain/ingredient/handler.go b/backend/internal/domain/ingredient/handler.go deleted file mode 100644 index 13d4fde..0000000 --- a/backend/internal/domain/ingredient/handler.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/domain/ingredient/repository.go b/backend/internal/domain/ingredient/repository.go deleted file mode 100644 index b73a60b..0000000 --- a/backend/internal/domain/ingredient/repository.go +++ /dev/null @@ -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() -} diff --git a/backend/internal/domain/menu/repository.go b/backend/internal/domain/menu/repository.go index b0bf23e..29d3598 100644 --- a/backend/internal/domain/menu/repository.go +++ b/backend/internal/domain/menu/repository.go @@ -268,15 +268,15 @@ func (r *Repository) GetPlanIDByWeek(ctx context.Context, userID, weekStart stri 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) { 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 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 - ORDER BY ri.sort_order`, planID) + ORDER BY rp.sort_order`, planID) if err != nil { return nil, fmt.Errorf("get ingredients by plan: %w", err) } diff --git a/backend/internal/domain/product/entity.go b/backend/internal/domain/product/entity.go index 5d6b679..679f592 100644 --- a/backend/internal/domain/product/entity.go +++ b/backend/internal/domain/product/entity.go @@ -1,41 +1,29 @@ 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 { - ID string `json:"id"` - UserID string `json:"user_id"` - PrimaryIngredientID *string `json:"primary_ingredient_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"` -} + 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"` + DefaultUnitName *string `json:"default_unit_name,omitempty"` + Barcode *string `json:"barcode,omitempty"` -// CreateRequest is the body for POST /products. -type CreateRequest struct { - PrimaryIngredientID *string `json:"primary_ingredient_id"` - // Accept both "primary_ingredient_id" (new) and "mapping_id" (legacy client) fields. - 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"` -} + 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"` -// UpdateRequest is the body for PUT /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"` + StorageDays *int `json:"storage_days"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/domain/product/handler.go b/backend/internal/domain/product/handler.go index e93b3ff..00871a6 100644 --- a/backend/internal/domain/product/handler.go +++ b/backend/internal/domain/product/handler.go @@ -3,145 +3,121 @@ package product import ( "context" "encoding/json" - "errors" "log/slog" "net/http" + "strconv" - "github.com/food-ai/backend/internal/infra/middleware" "github.com/go-chi/chi/v5" ) -// ProductRepository is the data layer interface used by Handler. -type ProductRepository interface { - List(ctx context.Context, userID string) ([]*Product, error) - Create(ctx context.Context, userID string, req CreateRequest) (*Product, error) - BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*Product, error) - Update(ctx context.Context, id, userID string, req UpdateRequest) (*Product, error) - Delete(ctx context.Context, id, userID string) error +// ProductSearcher is the data layer interface used by Handler for search. +type ProductSearcher interface { + Search(ctx context.Context, query string, limit int) ([]*Product, error) + GetByBarcode(ctx context.Context, barcode string) (*Product, error) + UpsertByBarcode(ctx context.Context, catalogProduct *Product) (*Product, 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 { - repo ProductRepository + repo ProductSearcher + openFoodFacts OpenFoodFactsClient } // NewHandler creates a new Handler. -func NewHandler(repo ProductRepository) *Handler { - return &Handler{repo: repo} +func NewHandler(repo ProductSearcher, openFoodFacts OpenFoodFactsClient) *Handler { + return &Handler{repo: repo, openFoodFacts: openFoodFacts} } -// List handles GET /products. -func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - userID := middleware.UserIDFromCtx(r.Context()) - products, err := h.repo.List(r.Context(), userID) - if err != nil { - slog.Error("list products", "user_id", userID, "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to list products") +// Search handles GET /products/search?q=&limit=10. +func (handler *Handler) Search(responseWriter http.ResponseWriter, request *http.Request) { + query := request.URL.Query().Get("q") + if query == "" { + responseWriter.Header().Set("Content-Type", "application/json") + _, _ = responseWriter.Write([]byte("[]")) 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 { products = []*Product{} } - writeJSON(w, http.StatusOK, products) + + responseWriter.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(responseWriter).Encode(products) } -// Create handles POST /products. -func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { - userID := middleware.UserIDFromCtx(r.Context()) - var req CreateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErrorJSON(w, http.StatusBadRequest, "invalid request body") - return - } - if req.Name == "" { - writeErrorJSON(w, http.StatusBadRequest, "name is required") +// GetByBarcode handles GET /products/barcode/{barcode}. +// Checks the database first; on miss, fetches from Open Food Facts and caches the result. +func (handler *Handler) GetByBarcode(responseWriter http.ResponseWriter, request *http.Request) { + barcode := chi.URLParam(request, "barcode") + if barcode == "" { + writeErrorJSON(responseWriter, http.StatusBadRequest, "barcode is required") return } - p, err := h.repo.Create(r.Context(), userID, req) - if err != nil { - slog.Error("create product", "user_id", userID, "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to create product") + // Check the local catalog first. + catalogProduct, lookupError := handler.repo.GetByBarcode(request.Context(), barcode) + if lookupError != nil { + slog.Error("lookup product by barcode", "barcode", barcode, "err", lookupError) + writeErrorJSON(responseWriter, http.StatusInternalServerError, "lookup failed") return } - writeJSON(w, http.StatusCreated, p) -} - -// 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{}) + if catalogProduct != nil { + writeJSON(responseWriter, http.StatusOK, catalogProduct) return } - products, err := h.repo.BatchCreate(r.Context(), userID, items) - if err != nil { - slog.Error("batch create products", "user_id", userID, "err", err) - writeErrorJSON(w, http.StatusInternalServerError, "failed to create products") - return - } - 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") + // Not in catalog — fetch from Open Food Facts. + fetchedProduct, fetchError := handler.openFoodFacts.Fetch(request.Context(), barcode) + if fetchError != nil { + slog.Warn("open food facts fetch failed", "barcode", barcode, "err", fetchError) + writeErrorJSON(responseWriter, http.StatusNotFound, "product not found") return } - p, err := h.repo.Update(r.Context(), id, userID, req) - if errors.Is(err, ErrNotFound) { - writeErrorJSON(w, http.StatusNotFound, "product not found") + // Persist the fetched product so subsequent lookups are served from the DB. + savedProduct, upsertError := handler.repo.UpsertByBarcode(request.Context(), fetchedProduct) + 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 } - if err != nil { - 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) + writeJSON(responseWriter, http.StatusOK, savedProduct) } type errorResponse struct { Error string `json:"error"` } -func writeErrorJSON(w http.ResponseWriter, status int, msg string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(errorResponse{Error: msg}) +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(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) +func writeJSON(responseWriter http.ResponseWriter, status int, value any) { + responseWriter.Header().Set("Content-Type", "application/json") + responseWriter.WriteHeader(status) + _ = json.NewEncoder(responseWriter).Encode(value) } diff --git a/backend/internal/domain/product/mocks/repository.go b/backend/internal/domain/product/mocks/repository.go deleted file mode 100644 index a0a38d8..0000000 --- a/backend/internal/domain/product/mocks/repository.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/domain/product/openfoodfacts.go b/backend/internal/domain/product/openfoodfacts.go new file mode 100644 index 0000000..708d5cd --- /dev/null +++ b/backend/internal/domain/product/openfoodfacts.go @@ -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 +} diff --git a/backend/internal/domain/product/repository.go b/backend/internal/domain/product/repository.go index 79cb586..4e9ca87 100644 --- a/backend/internal/domain/product/repository.go +++ b/backend/internal/domain/product/repository.go @@ -2,18 +2,16 @@ package product import ( "context" + "encoding/json" "errors" "fmt" - "time" + "github.com/food-ai/backend/internal/infra/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -// ErrNotFound is returned when a product is not found or does not belong to the user. -var ErrNotFound = errors.New("product not found") - -// Repository handles product persistence. +// Repository handles persistence for catalog products and their translations. type Repository struct { pool *pgxpool.Pool } @@ -23,180 +21,314 @@ 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_ingredient_id, name, quantity, unit, category, storage_days, added_at, - (added_at + storage_days * INTERVAL '1 day') AS expires_at` +// Upsert inserts or updates a catalog product (English canonical content). +// Conflict is resolved on canonical_name. +func (r *Repository) Upsert(requestContext context.Context, catalogProduct *Product) (*Product, error) { + 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. -func (r *Repository) List(ctx context.Context, userID string) ([]*Product, error) { - rows, err := r.pool.Query(ctx, ` - SELECT `+selectCols+` - FROM products - WHERE user_id = $1 - ORDER BY expires_at ASC`, userID) - if err != nil { - return nil, fmt.Errorf("list products: %w", err) + row := r.pool.QueryRow(requestContext, query, + catalogProduct.CanonicalName, + catalogProduct.Category, catalogProduct.DefaultUnit, + catalogProduct.CaloriesPer100g, catalogProduct.ProteinPer100g, catalogProduct.FatPer100g, catalogProduct.CarbsPer100g, catalogProduct.FiberPer100g, + catalogProduct.StorageDays, + ) + return scanProductWrite(row) +} + +// 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() - return collectProducts(rows) + return collectProductsRead(rows) } -// Create inserts a new product and returns the created record. -func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Product, error) { - storageDays := req.StorageDays - if storageDays <= 0 { - storageDays = 7 +// Count returns the total number of catalog products. +func (r *Repository) Count(requestContext context.Context) (int, error) { + var count int + if queryError := r.pool.QueryRow(requestContext, `SELECT count(*) FROM products`).Scan(&count); queryError != nil { + return 0, fmt.Errorf("count products: %w", queryError) } - unit := req.Unit - 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) + return count, nil } -// BatchCreate inserts multiple products sequentially and returns all created records. -func (r *Repository) BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*Product, error) { - var result []*Product - for _, req := range items { - p, err := r.Create(ctx, userID, req) - if err != nil { - return nil, fmt.Errorf("batch create product %q: %w", req.Name, err) +// UpsertAliases inserts aliases for a given catalog product and language. +func (r *Repository) UpsertAliases(requestContext 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 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 } -// ListForPrompt returns a human-readable list of user's products for the AI prompt. -// Expiring soon items are marked with ⚠. -func (r *Repository) ListForPrompt(ctx context.Context, userID string) ([]string, error) { - rows, err := r.pool.Query(ctx, ` - WITH p AS ( - SELECT name, quantity, unit, - (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() +// UpsertCategoryTranslation inserts or replaces a localized category name. +func (r *Repository) UpsertCategoryTranslation(requestContext context.Context, slug, lang, name string) error { + query := ` + INSERT INTO product_category_translations (product_category_slug, lang, name) + VALUES ($1, $2, $3) + ON CONFLICT (product_category_slug, lang) DO UPDATE SET name = EXCLUDED.name` - var lines []string - now := time.Now() - 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) + if _, execError := r.pool.Exec(requestContext, query, slug, lang, name); execError != nil { + return fmt.Errorf("upsert category translation %s/%s: %w", slug, lang, execError) } - return lines, rows.Err() + return nil } -// --- helpers --- +// --- scan helpers --- -func scanProduct(row pgx.Row) (*Product, error) { - var p Product - err := row.Scan( - &p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit, - &p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt, +func scanProductWrite(row pgx.Row) (*Product, error) { + var catalogProduct Product + scanError := row.Scan( + &catalogProduct.ID, &catalogProduct.CanonicalName, &catalogProduct.Category, &catalogProduct.DefaultUnit, + &catalogProduct.CaloriesPer100g, &catalogProduct.ProteinPer100g, &catalogProduct.FatPer100g, &catalogProduct.CarbsPer100g, &catalogProduct.FiberPer100g, + &catalogProduct.StorageDays, &catalogProduct.CreatedAt, &catalogProduct.UpdatedAt, ) - if err != nil { - return nil, err + if scanError != nil { + return nil, scanError } - computeDaysLeft(&p) - return &p, nil + catalogProduct.Aliases = json.RawMessage("[]") + 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 for rows.Next() { - var p Product - if err := rows.Scan( - &p.ID, &p.UserID, &p.PrimaryIngredientID, &p.Name, &p.Quantity, &p.Unit, - &p.Category, &p.StorageDays, &p.AddedAt, &p.ExpiresAt, - ); err != nil { - return nil, fmt.Errorf("scan product: %w", err) + var catalogProduct Product + if scanError := rows.Scan( + &catalogProduct.ID, &catalogProduct.CanonicalName, &catalogProduct.Category, &catalogProduct.DefaultUnit, + &catalogProduct.CaloriesPer100g, &catalogProduct.ProteinPer100g, &catalogProduct.FatPer100g, &catalogProduct.CarbsPer100g, &catalogProduct.FiberPer100g, + &catalogProduct.StorageDays, &catalogProduct.CreatedAt, &catalogProduct.UpdatedAt, + ); scanError != nil { + return nil, fmt.Errorf("scan product: %w", scanError) } - computeDaysLeft(&p) - result = append(result, &p) + catalogProduct.Aliases = json.RawMessage("[]") + result = append(result, &catalogProduct) } return result, rows.Err() } -func computeDaysLeft(p *Product) { - d := int(time.Until(p.ExpiresAt).Hours() / 24) - if d < 0 { - d = 0 +func collectProductsRead(rows pgx.Rows) ([]*Product, error) { + var result []*Product + for rows.Next() { + 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 - p.ExpiringSoon = d <= 3 + return result, rows.Err() } diff --git a/backend/internal/domain/recognition/handler.go b/backend/internal/domain/recognition/handler.go index f2cdc27..03d53dc 100644 --- a/backend/internal/domain/recognition/handler.go +++ b/backend/internal/domain/recognition/handler.go @@ -12,7 +12,7 @@ import ( "github.com/food-ai/backend/internal/adapters/ai" "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/middleware" ) @@ -26,12 +26,10 @@ type DishRepository interface { AddRecipe(ctx context.Context, dishID string, req dish.CreateRequest) (string, error) } -// IngredientRepository is the subset of ingredient.Repository used by this handler. -type IngredientRepository interface { - FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error) - Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error) - UpsertTranslation(ctx context.Context, id, lang, name string) error - UpsertAliases(ctx context.Context, id, lang string, aliases []string) error +// ProductRepository is the subset of product.Repository used by this handler. +type ProductRepository interface { + FuzzyMatch(ctx context.Context, name string) (*product.Product, error) + Upsert(ctx context.Context, catalogProduct *product.Product) (*product.Product, error) } // Recognizer is the AI provider interface for image-based food recognition. @@ -51,27 +49,27 @@ type KafkaPublisher interface { // Handler handles POST /ai/* recognition endpoints. type Handler struct { - recognizer Recognizer - ingredientRepo IngredientRepository - jobRepo JobRepository - kafkaProducer KafkaPublisher - sseBroker *SSEBroker + recognizer Recognizer + productRepo ProductRepository + jobRepo JobRepository + kafkaProducer KafkaPublisher + sseBroker *SSEBroker } // NewHandler creates a new Handler with async dish recognition support. func NewHandler( recognizer Recognizer, - ingredientRepo IngredientRepository, + productRepo ProductRepository, jobRepo JobRepository, kafkaProducer KafkaPublisher, sseBroker *SSEBroker, ) *Handler { return &Handler{ - recognizer: recognizer, - ingredientRepo: ingredientRepo, - jobRepo: jobRepo, - kafkaProducer: kafkaProducer, - sseBroker: sseBroker, + recognizer: recognizer, + productRepo: productRepo, + jobRepo: jobRepo, + kafkaProducer: kafkaProducer, + sseBroker: sseBroker, } } @@ -293,7 +291,7 @@ func (handler *Handler) GetJob(responseWriter http.ResponseWriter, request *http // 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. func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []EnrichedItem { result := make([]EnrichedItem, 0, len(items)) @@ -307,27 +305,27 @@ func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedIt StorageDays: 7, // sensible default } - mapping, matchError := handler.ingredientRepo.FuzzyMatch(ctx, item.Name) + catalogProduct, matchError := handler.productRepo.FuzzyMatch(ctx, item.Name) 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 { - id := mapping.ID + if catalogProduct != nil { + id := catalogProduct.ID enriched.MappingID = &id - if mapping.DefaultUnit != nil { - enriched.Unit = *mapping.DefaultUnit + if catalogProduct.DefaultUnit != nil { + enriched.Unit = *catalogProduct.DefaultUnit } - if mapping.StorageDays != nil { - enriched.StorageDays = *mapping.StorageDays + if catalogProduct.StorageDays != nil { + enriched.StorageDays = *catalogProduct.StorageDays } - if mapping.Category != nil { - enriched.Category = *mapping.Category + if catalogProduct.Category != nil { + enriched.Category = *catalogProduct.Category } } else { classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name) if classifyError != nil { - slog.Warn("classify unknown ingredient", "name", item.Name, "err", classifyError) + slog.Warn("classify unknown product", "name", item.Name, "err", classifyError) } else { saved := handler.saveClassification(ctx, classification) if saved != nil { @@ -344,13 +342,13 @@ func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedIt return result } -// saveClassification upserts an AI-produced ingredient classification into the DB. -func (handler *Handler) saveClassification(ctx context.Context, classification *ai.IngredientClassification) *ingredient.IngredientMapping { +// saveClassification upserts an AI-produced classification into the product catalog. +func (handler *Handler) saveClassification(ctx context.Context, classification *ai.IngredientClassification) *product.Product { if classification == nil || classification.CanonicalName == "" { return nil } - mapping := &ingredient.IngredientMapping{ + catalogProduct := &product.Product{ CanonicalName: classification.CanonicalName, Category: strPtr(classification.Category), DefaultUnit: strPtr(classification.DefaultUnit), @@ -361,29 +359,12 @@ func (handler *Handler) saveClassification(ctx context.Context, classification * StorageDays: intPtr(classification.StorageDays), } - saved, upsertError := handler.ingredientRepo.Upsert(ctx, mapping) + saved, upsertError := handler.productRepo.Upsert(ctx, catalogProduct) 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 } - 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 } diff --git a/backend/internal/domain/userproduct/entity.go b/backend/internal/domain/userproduct/entity.go new file mode 100644 index 0000000..f71beb9 --- /dev/null +++ b/backend/internal/domain/userproduct/entity.go @@ -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"` +} diff --git a/backend/internal/domain/userproduct/handler.go b/backend/internal/domain/userproduct/handler.go new file mode 100644 index 0000000..7003598 --- /dev/null +++ b/backend/internal/domain/userproduct/handler.go @@ -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) +} diff --git a/backend/internal/domain/userproduct/mocks/repository.go b/backend/internal/domain/userproduct/mocks/repository.go new file mode 100644 index 0000000..018063f --- /dev/null +++ b/backend/internal/domain/userproduct/mocks/repository.go @@ -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) +} diff --git a/backend/internal/domain/userproduct/repository.go b/backend/internal/domain/userproduct/repository.go new file mode 100644 index 0000000..062b307 --- /dev/null +++ b/backend/internal/domain/userproduct/repository.go @@ -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 +} diff --git a/backend/internal/infra/server/server.go b/backend/internal/infra/server/server.go index 8f0c4c4..6107df9 100644 --- a/backend/internal/infra/server/server.go +++ b/backend/internal/infra/server/server.go @@ -8,16 +8,16 @@ import ( "github.com/food-ai/backend/internal/domain/diary" "github.com/food-ai/backend/internal/domain/dish" "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/menu" "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/recipe" "github.com/food-ai/backend/internal/domain/recognition" "github.com/food-ai/backend/internal/domain/recommendation" "github.com/food-ai/backend/internal/domain/savedrecipe" "github.com/food-ai/backend/internal/domain/user" + "github.com/food-ai/backend/internal/domain/userproduct" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -28,8 +28,8 @@ func NewRouter( userHandler *user.Handler, recommendationHandler *recommendation.Handler, savedRecipeHandler *savedrecipe.Handler, - ingredientHandler *ingredient.Handler, productHandler *product.Handler, + userProductHandler *userproduct.Handler, recognitionHandler *recognition.Handler, menuHandler *menu.Handler, diaryHandler *diary.Handler, @@ -67,7 +67,8 @@ func NewRouter( r.Group(func(r chi.Router) { 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.Put("/profile", userHandler.Update) @@ -81,12 +82,12 @@ func NewRouter( r.Delete("/{id}", savedRecipeHandler.Delete) }) - r.Route("/products", func(r chi.Router) { - r.Get("/", productHandler.List) - r.Post("/", productHandler.Create) - r.Post("/batch", productHandler.BatchCreate) - r.Put("/{id}", productHandler.Update) - r.Delete("/{id}", productHandler.Delete) + r.Route("/user-products", func(r chi.Router) { + r.Get("/", userProductHandler.List) + r.Post("/", userProductHandler.Create) + r.Post("/batch", userProductHandler.BatchCreate) + r.Put("/{id}", userProductHandler.Update) + r.Delete("/{id}", userProductHandler.Delete) }) r.Route("/dishes", func(r chi.Router) { diff --git a/backend/migrations/001_initial_schema.sql b/backend/migrations/001_initial_schema.sql index 785a81c..7b4a598 100644 --- a/backend/migrations/001_initial_schema.sql +++ b/backend/migrations/001_initial_schema.sql @@ -44,11 +44,11 @@ $$ LANGUAGE plpgsql VOLATILE; -- --------------------------------------------------------------------------- -- Enums -- --------------------------------------------------------------------------- -CREATE TYPE user_plan AS ENUM ('free', 'paid'); -CREATE TYPE user_gender AS ENUM ('male', 'female'); -CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain'); -CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high'); -CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user'); +CREATE TYPE user_plan AS ENUM ('free', 'paid'); +CREATE TYPE user_gender AS ENUM ('male', 'female'); +CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain'); +CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high'); +CREATE TYPE recipe_source AS ENUM ('spoonacular', 'ai', 'user'); 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, + name TEXT NOT NULL, sort_order SMALLINT NOT NULL DEFAULT 0 ); -CREATE TABLE ingredient_category_translations ( - category_slug VARCHAR(50) NOT NULL REFERENCES ingredient_categories(slug) ON DELETE CASCADE, - lang VARCHAR(10) NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (category_slug, lang) +CREATE TABLE product_category_translations ( + product_category_slug VARCHAR(50) NOT NULL REFERENCES product_categories(slug) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + 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(), 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), + barcode TEXT UNIQUE, calories_per_100g DECIMAL(8,2), protein_per_100g DECIMAL(8,2), fat_per_100g DECIMAL(8,2), @@ -134,33 +136,23 @@ CREATE TABLE ingredients ( storage_days INTEGER, created_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_ingredients_category ON ingredients (category); +CREATE INDEX idx_products_canonical_name ON products (canonical_name); +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 ( - ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, - lang VARCHAR(10) NOT NULL, - name VARCHAR(255) NOT NULL, - PRIMARY KEY (ingredient_id, lang) +CREATE TABLE product_aliases ( + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + alias TEXT NOT NULL, + PRIMARY KEY (product_id, lang, alias) ); -CREATE INDEX idx_ingredient_translations_ingredient_id ON ingredient_translations (ingredient_id); - --- --------------------------------------------------------------------------- --- 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); +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); -- --------------------------------------------------------------------------- -- 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 ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, - ingredient_id UUID REFERENCES ingredients(id) ON DELETE SET NULL, - name TEXT NOT NULL, - amount DECIMAL(10,2) NOT NULL DEFAULT 0, - unit_code VARCHAR(20), - is_optional BOOLEAN NOT NULL DEFAULT false, - sort_order SMALLINT NOT NULL DEFAULT 0 +CREATE TABLE recipe_products ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + product_id UUID REFERENCES products(id) ON DELETE SET NULL, + name TEXT NOT NULL, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + unit_code VARCHAR(20), + is_optional BOOLEAN NOT NULL DEFAULT false, + 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 ( - ri_id UUID NOT NULL REFERENCES recipe_ingredients(id) ON DELETE CASCADE, +CREATE TABLE recipe_product_translations ( + ri_id UUID NOT NULL REFERENCES recipe_products(id) ON DELETE CASCADE, lang VARCHAR(10) NOT NULL, name TEXT NOT NULL, 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 ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - primary_ingredient_id UUID REFERENCES ingredients(id), - name TEXT NOT NULL, - quantity DECIMAL(10,2) NOT NULL DEFAULT 1, - unit TEXT NOT NULL DEFAULT 'pcs' REFERENCES units(code), - category TEXT, - storage_days INT NOT NULL DEFAULT 7, - added_at TIMESTAMPTZ NOT NULL DEFAULT now() +CREATE TABLE user_products ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + primary_product_id UUID REFERENCES products(id), + name TEXT NOT NULL, + quantity DECIMAL(10,2) NOT NULL DEFAULT 1, + unit TEXT NOT NULL DEFAULT 'pcs' REFERENCES units(code), + category TEXT, + storage_days INT NOT NULL DEFAULT 7, + 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 ( - product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, - ingredient_id UUID NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, +CREATE TABLE user_product_components ( + user_product_id UUID NOT NULL REFERENCES user_products(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE, 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 -- --------------------------------------------------------------------------- CREATE TABLE dish_recognition_jobs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - user_plan TEXT NOT NULL, - image_base64 TEXT NOT NULL, - mime_type TEXT NOT NULL DEFAULT 'image/jpeg', - lang TEXT NOT NULL DEFAULT 'en', - target_date DATE, + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_plan TEXT NOT NULL, + image_base64 TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'image/jpeg', + lang TEXT NOT NULL DEFAULT 'en', + target_date DATE, target_meal_type TEXT, - status TEXT NOT NULL DEFAULT 'pending', + status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | done | failed - result JSONB, - error TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ + result JSONB, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ ); CREATE INDEX idx_dish_recognition_jobs_user ON dish_recognition_jobs (user_id, created_at DESC); @@ -420,16 +412,18 @@ CREATE INDEX idx_dish_recognition_jobs_pending -- --------------------------------------------------------------------------- CREATE TABLE meal_diary ( 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, meal_type TEXT NOT NULL, portions DECIMAL(5,2) NOT NULL DEFAULT 1, source TEXT NOT NULL DEFAULT 'manual', - dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT, - recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, + dish_id UUID REFERENCES dishes(id) ON DELETE RESTRICT, + product_id UUID REFERENCES products(id) ON DELETE RESTRICT, + recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, portion_g DECIMAL(10,2), - job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL, + 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_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', 'уп'); -- --------------------------------------------------------------------------- --- Seed data: ingredient_categories + ingredient_category_translations +-- Seed data: product_categories + product_category_translations -- --------------------------------------------------------------------------- -INSERT INTO ingredient_categories (slug, sort_order) VALUES - ('dairy', 1), - ('meat', 2), - ('produce', 3), - ('bakery', 4), - ('frozen', 5), - ('beverages', 6), - ('other', 7); +INSERT INTO product_categories (slug, name, sort_order) VALUES + ('dairy', 'Dairy', 1), + ('meat', 'Meat', 2), + ('produce', 'Produce', 3), + ('bakery', 'Bakery', 4), + ('frozen', 'Frozen', 5), + ('beverages', 'Beverages', 6), + ('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', 'Молочные продукты'), ('meat', 'ru', 'Мясо и птица'), ('produce', 'ru', 'Овощи и фрукты'), @@ -617,12 +611,12 @@ DROP TABLE IF EXISTS shopping_lists; DROP TABLE IF EXISTS menu_items; DROP TABLE IF EXISTS menu_plans; DROP TABLE IF EXISTS user_saved_recipes; -DROP TABLE IF EXISTS product_ingredients; -DROP TABLE IF EXISTS products; +DROP TABLE IF EXISTS user_product_components; +DROP TABLE IF EXISTS user_products; DROP TABLE IF EXISTS recipe_step_translations; DROP TABLE IF EXISTS recipe_steps; -DROP TABLE IF EXISTS recipe_ingredient_translations; -DROP TABLE IF EXISTS recipe_ingredients; +DROP TABLE IF EXISTS recipe_product_translations; +DROP TABLE IF EXISTS recipe_products; DROP TABLE IF EXISTS recipe_translations; DROP TABLE IF EXISTS recipes; 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 cuisine_translations; DROP TABLE IF EXISTS cuisines; -DROP TABLE IF EXISTS ingredient_aliases; -DROP TABLE IF EXISTS ingredient_translations; -DROP TABLE IF EXISTS ingredients; -DROP TABLE IF EXISTS ingredient_category_translations; -DROP TABLE IF EXISTS ingredient_categories; +DROP TABLE IF EXISTS product_aliases; +DROP TABLE IF EXISTS products; +DROP TABLE IF EXISTS product_category_translations; +DROP TABLE IF EXISTS product_categories; DROP TABLE IF EXISTS unit_translations; DROP TABLE IF EXISTS units; DROP TABLE IF EXISTS languages; diff --git a/backend/tests/diary/handler_test.go b/backend/tests/diary/handler_test.go index 021a683..b77b780 100644 --- a/backend/tests/diary/handler_test.go +++ b/backend/tests/diary/handler_test.go @@ -140,7 +140,7 @@ func TestCreate_Success(t *testing.T) { Name: "Oatmeal", Portions: 1, Source: "manual", - DishID: "dish-1", + DishID: strPtr("dish-1"), CreatedAt: time.Now(), }, nil }, @@ -207,3 +207,5 @@ func TestDelete_Success(t *testing.T) { t.Errorf("expected 204, got %d", recorder.Code) } } + +func strPtr(s string) *string { return &s } diff --git a/backend/tests/ingredient/handler_test.go b/backend/tests/ingredient/handler_test.go index cd41794..987636a 100644 --- a/backend/tests/ingredient/handler_test.go +++ b/backend/tests/ingredient/handler_test.go @@ -1,25 +1,44 @@ -package ingredient_test +package product_catalog_test import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "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/go-chi/chi/v5" ) -// mockIngredientSearcher is an inline mock for ingredient.IngredientSearcher. -type mockIngredientSearcher struct { - searchFn func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) +// mockProductSearcher is an inline mock for product.ProductSearcher (catalog search only). +type mockProductSearcher struct { + 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) { - return m.searchFn(ctx, query, limit) +func (m *mockProductSearcher) Search(ctx context.Context, query string, limit int) ([]*product.Product, error) { + 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 } @@ -28,10 +47,10 @@ func (v *alwaysAuthValidator) ValidateAccessToken(_ string) (*middleware.TokenCl 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.Use(middleware.Auth(&alwaysAuthValidator{userID: "user-1"})) - router.Get("/ingredients/search", handler.Search) + router.Get("/products/search", handler.Search) return router } @@ -43,11 +62,11 @@ func authorizedRequest(target string) *http.Request { func TestSearch_EmptyQuery_ReturnsEmptyArray(t *testing.T) { // When q is empty, the handler returns [] without calling the repository. - handler := ingredient.NewHandler(&mockIngredientSearcher{}) + handler := product.NewHandler(&mockProductSearcher{}, &mockOpenFoodFacts{}) router := buildRouter(handler) recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, authorizedRequest("/ingredients/search")) + router.ServeHTTP(recorder, authorizedRequest("/products/search")) if recorder.Code != http.StatusOK { 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) { // When limit > 50, the handler ignores it and uses default 10. calledLimit := 0 - mockRepo := &mockIngredientSearcher{ - searchFn: func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { + mockRepo := &mockProductSearcher{ + searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) { calledLimit = limit - return []*ingredient.IngredientMapping{}, nil + return []*product.Product{}, nil }, } - handler := ingredient.NewHandler(mockRepo) + handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{}) router := buildRouter(handler) 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 { 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) { // When no limit is supplied, the handler uses default 10. calledLimit := 0 - mockRepo := &mockIngredientSearcher{ - searchFn: func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { + mockRepo := &mockProductSearcher{ + searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) { calledLimit = limit - return []*ingredient.IngredientMapping{}, nil + return []*product.Product{}, nil }, } - handler := ingredient.NewHandler(mockRepo) + handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{}) router := buildRouter(handler) recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, authorizedRequest("/ingredients/search?q=apple")) + router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple")) if recorder.Code != http.StatusOK { t.Errorf("expected 200, got %d", recorder.Code) @@ -106,17 +125,17 @@ func TestSearch_DefaultLimit(t *testing.T) { func TestSearch_ValidLimit(t *testing.T) { // limit=25 is within range and should be forwarded as-is. calledLimit := 0 - mockRepo := &mockIngredientSearcher{ - searchFn: func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { + mockRepo := &mockProductSearcher{ + searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) { calledLimit = limit - return []*ingredient.IngredientMapping{}, nil + return []*product.Product{}, nil }, } - handler := ingredient.NewHandler(mockRepo) + handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{}) router := buildRouter(handler) 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 { t.Errorf("expected 200, got %d", recorder.Code) @@ -127,31 +146,31 @@ func TestSearch_ValidLimit(t *testing.T) { } func TestSearch_Success(t *testing.T) { - mockRepo := &mockIngredientSearcher{ - searchFn: func(ctx context.Context, query string, limit int) ([]*ingredient.IngredientMapping, error) { - return []*ingredient.IngredientMapping{ - {ID: "ing-1", CanonicalName: "apple"}, + mockRepo := &mockProductSearcher{ + searchFn: func(ctx context.Context, query string, limit int) ([]*product.Product, error) { + return []*product.Product{ + {ID: "prod-1", CanonicalName: "apple"}, }, nil }, } - handler := ingredient.NewHandler(mockRepo) + handler := product.NewHandler(mockRepo, &mockOpenFoodFacts{}) router := buildRouter(handler) recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, authorizedRequest("/ingredients/search?q=apple")) + router.ServeHTTP(recorder, authorizedRequest("/products/search?q=apple")) if recorder.Code != http.StatusOK { t.Errorf("expected 200, got %d", recorder.Code) } - var mappings []ingredient.IngredientMapping - if decodeError := json.NewDecoder(recorder.Body).Decode(&mappings); decodeError != nil { + var products []product.Product + if decodeError := json.NewDecoder(recorder.Body).Decode(&products); decodeError != nil { t.Fatalf("decode response: %v", decodeError) } - if len(mappings) != 1 { - t.Errorf("expected 1 result, got %d", len(mappings)) + if len(products) != 1 { + t.Errorf("expected 1 result, got %d", len(products)) } - if mappings[0].CanonicalName != "apple" { - t.Errorf("expected canonical_name=apple, got %q", mappings[0].CanonicalName) + if products[0].CanonicalName != "apple" { + t.Errorf("expected canonical_name=apple, got %q", products[0].CanonicalName) } } diff --git a/backend/tests/ingredient/repository_integration_test.go b/backend/tests/ingredient/repository_integration_test.go index fd853c0..7256ec1 100644 --- a/backend/tests/ingredient/repository_integration_test.go +++ b/backend/tests/ingredient/repository_integration_test.go @@ -1,33 +1,33 @@ //go:build integration -package ingredient_test +package product_catalog_test import ( "context" "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/testutil" ) -func TestIngredientRepository_Upsert_Insert(t *testing.T) { +func TestProductRepository_Upsert_Insert(t *testing.T) { pool := testutil.SetupTestDB(t) - repo := ingredient.NewRepository(pool) + repo := product.NewRepository(pool) ctx := context.Background() cat := "produce" unit := "g" cal := 52.0 - mapping := &ingredient.IngredientMapping{ + catalogProduct := &product.Product{ CanonicalName: "apple", Category: &cat, DefaultUnit: &unit, CaloriesPer100g: &cal, } - got, upsertError := repo.Upsert(ctx, mapping) + got, upsertError := repo.Upsert(ctx, catalogProduct) if upsertError != nil { 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) - repo := ingredient.NewRepository(pool) + repo := product.NewRepository(pool) ctx := context.Background() cat := "produce" unit := "g" - first := &ingredient.IngredientMapping{ + first := &product.Product{ CanonicalName: "banana", Category: &cat, DefaultUnit: &unit, @@ -61,7 +61,7 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) { } cal := 89.0 - second := &ingredient.IngredientMapping{ + second := &product.Product{ CanonicalName: "banana", Category: &cat, 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) - repo := ingredient.NewRepository(pool) + repo := product.NewRepository(pool) ctx := context.Background() cat := "dairy" unit := "g" - saved, upsertError := repo.Upsert(ctx, &ingredient.IngredientMapping{ + saved, upsertError := repo.Upsert(ctx, &product.Product{ CanonicalName: "cheese", Category: &cat, 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) - repo := ingredient.NewRepository(pool) + repo := product.NewRepository(pool) ctx := context.Background() 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) - repo := ingredient.NewRepository(pool) + repo := product.NewRepository(pool) ctx := context.Background() cat := "produce" unit := "g" - // Insert 3 without any translation. - 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{ + saved, upsertError := repo.Upsert(ctx, &product.Product{ CanonicalName: "apple_test_aliases", Category: &cat, DefaultUnit: &unit, @@ -238,12 +150,10 @@ func TestIngredientRepository_UpsertAliases(t *testing.T) { t.Fatalf("upsert aliases: %v", aliasError) } - // Idempotent — second call should not fail. if aliasError := repo.UpsertAliases(ctx, saved.ID, "en", aliases); aliasError != nil { t.Fatalf("second upsert aliases: %v", aliasError) } - // Retrieve with English context — aliases should appear. englishContext := locale.WithLang(ctx, "en") got, getError := repo.GetByID(englishContext, saved.ID) 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) - repo := ingredient.NewRepository(pool) + repo := product.NewRepository(pool) ctx := context.Background() - // Upsert an ingredient with a known category. cat := "dairy" unit := "g" - saved, upsertError := repo.Upsert(ctx, &ingredient.IngredientMapping{ + saved, upsertError := repo.Upsert(ctx, &product.Product{ CanonicalName: "milk_test_category", Category: &cat, DefaultUnit: &unit, @@ -271,8 +180,6 @@ func TestIngredientRepository_UpsertCategoryTranslation(t *testing.T) { 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") got, getError := repo.GetByID(russianContext, saved.ID) if getError != nil { @@ -282,7 +189,6 @@ func TestIngredientRepository_UpsertCategoryTranslation(t *testing.T) { 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 { t.Fatalf("upsert category translation: %v", translationError) } diff --git a/backend/tests/product/handler_test.go b/backend/tests/userproduct/handler_test.go similarity index 58% rename from backend/tests/product/handler_test.go rename to backend/tests/userproduct/handler_test.go index f466874..1e8d809 100644 --- a/backend/tests/product/handler_test.go +++ b/backend/tests/userproduct/handler_test.go @@ -1,4 +1,4 @@ -package product_test +package userproduct_test import ( "bytes" @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/food-ai/backend/internal/domain/product" - productmocks "github.com/food-ai/backend/internal/domain/product/mocks" + "github.com/food-ai/backend/internal/domain/userproduct" + userproductmocks "github.com/food-ai/backend/internal/domain/userproduct/mocks" "github.com/food-ai/backend/internal/infra/middleware" "github.com/go-chi/chi/v5" ) @@ -21,14 +21,14 @@ func (v *alwaysAuthValidator) ValidateAccessToken(_ string) (*middleware.TokenCl 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.Use(middleware.Auth(&alwaysAuthValidator{userID: userID})) - router.Get("/products", handler.List) - router.Post("/products", handler.Create) - router.Post("/products/batch", handler.BatchCreate) - router.Put("/products/{id}", handler.Update) - router.Delete("/products/{id}", handler.Delete) + router.Get("/user-products", handler.List) + router.Post("/user-products", handler.Create) + router.Post("/user-products/batch", handler.BatchCreate) + router.Put("/user-products/{id}", handler.Update) + router.Delete("/user-products/{id}", handler.Delete) return router } @@ -39,8 +39,8 @@ func authorizedRequest(method, target string, body []byte) *http.Request { return request } -func makeProduct(name string) *product.Product { - return &product.Product{ +func makeUserProduct(name string) *userproduct.UserProduct { + return &userproduct.UserProduct{ ID: "prod-1", UserID: "user-1", Name: name, @@ -54,16 +54,16 @@ func makeProduct(name string) *product.Product { } func TestList_Success(t *testing.T) { - mockRepo := &productmocks.MockProductRepository{ - ListFn: func(ctx context.Context, userID string) ([]*product.Product, error) { - return []*product.Product{makeProduct("Milk")}, nil + mockRepo := &userproductmocks.MockUserProductRepository{ + ListFn: func(ctx context.Context, userID string) ([]*userproduct.UserProduct, error) { + return []*userproduct.UserProduct{makeUserProduct("Milk")}, nil }, } - handler := product.NewHandler(mockRepo) + handler := userproduct.NewHandler(mockRepo) router := buildRouter(handler, "user-1") 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 { t.Errorf("expected 200, got %d", recorder.Code) @@ -71,12 +71,12 @@ func TestList_Success(t *testing.T) { } func TestCreate_MissingName(t *testing.T) { - handler := product.NewHandler(&productmocks.MockProductRepository{}) + handler := userproduct.NewHandler(&userproductmocks.MockUserProductRepository{}) router := buildRouter(handler, "user-1") body, _ := json.Marshal(map[string]any{"quantity": 1}) 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 { t.Errorf("expected 400, got %d", recorder.Code) @@ -84,17 +84,17 @@ func TestCreate_MissingName(t *testing.T) { } func TestCreate_Success(t *testing.T) { - mockRepo := &productmocks.MockProductRepository{ - CreateFn: func(ctx context.Context, userID string, req product.CreateRequest) (*product.Product, error) { - return makeProduct(req.Name), nil + mockRepo := &userproductmocks.MockUserProductRepository{ + CreateFn: func(ctx context.Context, userID string, req userproduct.CreateRequest) (*userproduct.UserProduct, error) { + return makeUserProduct(req.Name), nil }, } - handler := product.NewHandler(mockRepo) + handler := userproduct.NewHandler(mockRepo) 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() - router.ServeHTTP(recorder, authorizedRequest(http.MethodPost, "/products", body)) + router.ServeHTTP(recorder, authorizedRequest(http.MethodPost, "/user-products", body)) if recorder.Code != http.StatusCreated { t.Errorf("expected 201, got %d", recorder.Code) @@ -102,24 +102,24 @@ func TestCreate_Success(t *testing.T) { } func TestBatchCreate_Success(t *testing.T) { - mockRepo := &productmocks.MockProductRepository{ - BatchCreateFn: func(ctx context.Context, userID string, items []product.CreateRequest) ([]*product.Product, error) { - result := make([]*product.Product, len(items)) + mockRepo := &userproductmocks.MockUserProductRepository{ + BatchCreateFn: func(ctx context.Context, userID string, items []userproduct.CreateRequest) ([]*userproduct.UserProduct, error) { + result := make([]*userproduct.UserProduct, len(items)) for index, item := range items { - result[index] = makeProduct(item.Name) + result[index] = makeUserProduct(item.Name) } return result, nil }, } - handler := product.NewHandler(mockRepo) + handler := userproduct.NewHandler(mockRepo) router := buildRouter(handler, "user-1") - body, _ := json.Marshal([]product.CreateRequest{ + body, _ := json.Marshal([]userproduct.CreateRequest{ {Name: "Milk", Quantity: 1, Unit: "L"}, {Name: "Eggs", Quantity: 12, Unit: "pcs"}, }) 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 { t.Errorf("expected 201, got %d", recorder.Code) @@ -127,18 +127,18 @@ func TestBatchCreate_Success(t *testing.T) { } func TestUpdate_NotFound(t *testing.T) { - mockRepo := &productmocks.MockProductRepository{ - UpdateFn: func(ctx context.Context, id, userID string, req product.UpdateRequest) (*product.Product, error) { - return nil, product.ErrNotFound + mockRepo := &userproductmocks.MockUserProductRepository{ + UpdateFn: func(ctx context.Context, id, userID string, req userproduct.UpdateRequest) (*userproduct.UserProduct, error) { + return nil, userproduct.ErrNotFound }, } - handler := product.NewHandler(mockRepo) + handler := userproduct.NewHandler(mockRepo) router := buildRouter(handler, "user-1") namePtr := "NewName" - body, _ := json.Marshal(product.UpdateRequest{Name: &namePtr}) + body, _ := json.Marshal(userproduct.UpdateRequest{Name: &namePtr}) 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 { t.Errorf("expected 404, got %d", recorder.Code) @@ -146,19 +146,19 @@ func TestUpdate_NotFound(t *testing.T) { } func TestUpdate_Success(t *testing.T) { - mockRepo := &productmocks.MockProductRepository{ - UpdateFn: func(ctx context.Context, id, userID string, req product.UpdateRequest) (*product.Product, error) { - updated := makeProduct(*req.Name) + mockRepo := &userproductmocks.MockUserProductRepository{ + UpdateFn: func(ctx context.Context, id, userID string, req userproduct.UpdateRequest) (*userproduct.UserProduct, error) { + updated := makeUserProduct(*req.Name) return updated, nil }, } - handler := product.NewHandler(mockRepo) + handler := userproduct.NewHandler(mockRepo) router := buildRouter(handler, "user-1") namePtr := "Oat Milk" - body, _ := json.Marshal(product.UpdateRequest{Name: &namePtr}) + body, _ := json.Marshal(userproduct.UpdateRequest{Name: &namePtr}) 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 { t.Errorf("expected 200, got %d", recorder.Code) @@ -166,16 +166,16 @@ func TestUpdate_Success(t *testing.T) { } func TestDelete_NotFound(t *testing.T) { - mockRepo := &productmocks.MockProductRepository{ + mockRepo := &userproductmocks.MockUserProductRepository{ 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") 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 { t.Errorf("expected 404, got %d", recorder.Code) @@ -183,16 +183,16 @@ func TestDelete_NotFound(t *testing.T) { } func TestDelete_Success(t *testing.T) { - mockRepo := &productmocks.MockProductRepository{ + mockRepo := &userproductmocks.MockUserProductRepository{ DeleteFn: func(ctx context.Context, id, userID string) error { return nil }, } - handler := product.NewHandler(mockRepo) + handler := userproduct.NewHandler(mockRepo) router := buildRouter(handler, "user-1") 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 { t.Errorf("expected 204, got %d", recorder.Code) diff --git a/backend/tests/product/repository_integration_test.go b/backend/tests/userproduct/repository_integration_test.go similarity index 66% rename from backend/tests/product/repository_integration_test.go rename to backend/tests/userproduct/repository_integration_test.go index f075320..5081c94 100644 --- a/backend/tests/product/repository_integration_test.go +++ b/backend/tests/userproduct/repository_integration_test.go @@ -1,29 +1,29 @@ //go:build integration -package product_test +package userproduct_test import ( "context" "testing" - "github.com/food-ai/backend/internal/domain/product" + "github.com/food-ai/backend/internal/domain/userproduct" "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) - repo := product.NewRepository(pool) + repo := userproduct.NewRepository(pool) requestContext := context.Background() // 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", StorageDays: 0, Unit: "", Quantity: 0, }) if createError != nil { - t.Fatalf("create product: %v", createError) + t.Fatalf("create user product: %v", createError) } if created.StorageDays != 7 { 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) - repo := product.NewRepository(pool) + repo := userproduct.NewRepository(pool) requestContext := context.Background() userID := "list-order-user" - // Create product with longer expiry first, shorter expiry second. - _, createError := repo.Create(requestContext, userID, product.CreateRequest{Name: "Milk", StorageDays: 14}) + _, createError := repo.Create(requestContext, userID, userproduct.CreateRequest{Name: "Milk", StorageDays: 14}) if createError != nil { 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 { t.Fatalf("create Butter: %v", createError) } products, listError := repo.List(requestContext, userID) if listError != nil { - t.Fatalf("list products: %v", listError) + t.Fatalf("list user products: %v", listError) } if len(products) != 2 { 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) - repo := product.NewRepository(pool) + repo := userproduct.NewRepository(pool) 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: "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) - repo := product.NewRepository(pool) + repo := userproduct.NewRepository(pool) requestContext := context.Background() newName := "Ghost" _, updateError := repo.Update(requestContext, "00000000-0000-0000-0000-000000000000", "any-user", - product.UpdateRequest{Name: &newName}) - if updateError != product.ErrNotFound { + userproduct.UpdateRequest{Name: &newName}) + if updateError != userproduct.ErrNotFound { 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) - repo := product.NewRepository(pool) + repo := userproduct.NewRepository(pool) 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 { - t.Fatalf("create product: %v", createError) + t.Fatalf("create user product: %v", createError) } 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) } } -func TestProductRepository_ListForPrompt(t *testing.T) { +func TestUserProductRepository_ListForPrompt(t *testing.T) { pool := testutil.SetupTestDB(t) - repo := product.NewRepository(pool) + repo := userproduct.NewRepository(pool) requestContext := context.Background() 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, }) if createError != nil { - t.Fatalf("create product: %v", createError) + t.Fatalf("create user product: %v", createError) } lines, listError := repo.ListForPrompt(requestContext, userID) diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index d224f7a..5912691 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -21,7 +21,7 @@ import '../../features/menu/shopping_list_screen.dart'; import '../../features/recipes/recipe_detail_screen.dart'; import '../../features/recipes/recipes_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 '../../shared/models/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); // 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, orElse: () => 0, ); diff --git a/client/lib/features/diary/barcode_scan_screen.dart b/client/lib/features/diary/barcode_scan_screen.dart new file mode 100644 index 0000000..ae1d7ff --- /dev/null +++ b/client/lib/features/diary/barcode_scan_screen.dart @@ -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 createState() => _BarcodeScanScreenState(); +} + +class _BarcodeScanScreenState extends ConsumerState { + 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 _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, + ), + ); + } +} diff --git a/client/lib/features/diary/food_product_service.dart b/client/lib/features/diary/food_product_service.dart new file mode 100644 index 0000000..eac2d49 --- /dev/null +++ b/client/lib/features/diary/food_product_service.dart @@ -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 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> 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)) + .toList(); + } +} diff --git a/client/lib/features/diary/product_portion_sheet.dart b/client/lib/features/diary/product_portion_sheet.dart new file mode 100644 index 0000000..0266a67 --- /dev/null +++ b/client/lib/features/diary/product_portion_sheet.dart @@ -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 createState() => + _ProductPortionSheetState(); +} + +class _ProductPortionSheetState extends ConsumerState { + 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, + ), + ), + ], + ); + } +} diff --git a/client/lib/features/products/add_product_screen.dart b/client/lib/features/products/add_product_screen.dart index a33ec6e..7fae7eb 100644 --- a/client/lib/features/products/add_product_screen.dart +++ b/client/lib/features/products/add_product_screen.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/locale/unit_provider.dart'; -import '../../shared/models/ingredient_mapping.dart'; -import 'product_provider.dart'; +import '../../shared/models/product.dart'; +import 'user_product_provider.dart'; class AddProductScreen extends ConsumerStatefulWidget { const AddProductScreen({super.key}); @@ -21,11 +21,11 @@ class _AddProductScreenState extends ConsumerState { String _unit = 'pcs'; String? _category; - String? _mappingId; + String? _primaryProductId; bool _saving = false; // Autocomplete state - List _suggestions = []; + List _suggestions = []; bool _searching = false; Timer? _debounce; @@ -42,7 +42,7 @@ class _AddProductScreenState extends ConsumerState { _debounce?.cancel(); // Reset mapping if user edits the name after selecting a suggestion setState(() { - _mappingId = null; + _primaryProductId = null; }); if (value.trim().isEmpty) { @@ -53,8 +53,8 @@ class _AddProductScreenState extends ConsumerState { _debounce = Timer(const Duration(milliseconds: 300), () async { setState(() => _searching = true); try { - final service = ref.read(productServiceProvider); - final results = await service.searchIngredients(value.trim()); + final service = ref.read(userProductServiceProvider); + final results = await service.searchProducts(value.trim()); if (mounted) setState(() => _suggestions = results); } finally { if (mounted) setState(() => _searching = false); @@ -62,16 +62,16 @@ class _AddProductScreenState extends ConsumerState { }); } - void _selectSuggestion(IngredientMapping mapping) { + void _selectSuggestion(CatalogProduct catalogProduct) { setState(() { - _nameController.text = mapping.displayName; - _mappingId = mapping.id; - _category = mapping.category; - if (mapping.defaultUnit != null) { - _unit = mapping.defaultUnit!; + _nameController.text = catalogProduct.displayName; + _primaryProductId = catalogProduct.id; + _category = catalogProduct.category; + if (catalogProduct.defaultUnit != null) { + _unit = catalogProduct.defaultUnit!; } - if (mapping.storageDays != null) { - _daysController.text = mapping.storageDays.toString(); + if (catalogProduct.storageDays != null) { + _daysController.text = catalogProduct.storageDays.toString(); } _suggestions = []; }); @@ -91,13 +91,13 @@ class _AddProductScreenState extends ConsumerState { setState(() => _saving = true); try { - await ref.read(productsProvider.notifier).create( + await ref.read(userProductsProvider.notifier).create( name: name, quantity: qty, unit: _unit, category: _category, storageDays: days, - mappingId: _mappingId, + primaryProductId: _primaryProductId, ); if (mounted) Navigator.pop(context); } catch (e) { diff --git a/client/lib/features/products/product_service.dart b/client/lib/features/products/product_service.dart deleted file mode 100644 index bf483a1..0000000 --- a/client/lib/features/products/product_service.dart +++ /dev/null @@ -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> getProducts() async { - final list = await _client.getList('/products'); - return list.map((e) => Product.fromJson(e as Map)).toList(); - } - - Future 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 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 deleteProduct(String id) => - _client.deleteVoid('/products/$id'); - - Future> 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)) - .toList(); - } -} diff --git a/client/lib/features/products/products_screen.dart b/client/lib/features/products/products_screen.dart index 40729ab..4d993ed 100644 --- a/client/lib/features/products/products_screen.dart +++ b/client/lib/features/products/products_screen.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/locale/unit_provider.dart'; -import '../../shared/models/product.dart'; -import 'product_provider.dart'; +import '../../shared/models/user_product.dart'; +import 'user_product_provider.dart'; void _showAddMenu(BuildContext context) { showModalBottomSheet( @@ -40,7 +40,7 @@ class ProductsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(productsProvider); + final state = ref.watch(userProductsProvider); return Scaffold( appBar: AppBar( @@ -48,7 +48,7 @@ class ProductsScreen extends ConsumerWidget { actions: [ IconButton( 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( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => _ErrorView( - onRetry: () => ref.read(productsProvider.notifier).refresh(), + onRetry: () => ref.read(userProductsProvider.notifier).refresh(), ), data: (products) => products.isEmpty ? _EmptyState( @@ -79,7 +79,7 @@ class ProductsScreen extends ConsumerWidget { class _ProductList extends ConsumerWidget { const _ProductList({required this.products}); - final List products; + final List products; @override Widget build(BuildContext context, WidgetRef ref) { @@ -87,7 +87,7 @@ class _ProductList extends ConsumerWidget { final rest = products.where((p) => !p.expiringSoon).toList(); return RefreshIndicator( - onRefresh: () => ref.read(productsProvider.notifier).refresh(), + onRefresh: () => ref.read(userProductsProvider.notifier).refresh(), child: ListView( padding: const EdgeInsets.only(bottom: 80), children: [ @@ -154,7 +154,7 @@ class _SectionHeader extends StatelessWidget { class _ProductTile extends ConsumerWidget { const _ProductTile({required this.product}); - final Product product; + final UserProduct product; @override Widget build(BuildContext context, WidgetRef ref) { @@ -194,7 +194,7 @@ class _ProductTile extends ConsumerWidget { ); }, onDismissed: (_) { - ref.read(productsProvider.notifier).delete(product.id); + ref.read(userProductsProvider.notifier).delete(product.id); }, child: ListTile( 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( context: context, isScrollControlled: true, @@ -290,7 +290,7 @@ class _ProductTile extends ConsumerWidget { class _EditProductSheet extends ConsumerStatefulWidget { const _EditProductSheet({required this.product}); - final Product product; + final UserProduct product; @override ConsumerState<_EditProductSheet> createState() => _EditProductSheetState(); @@ -382,7 +382,7 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> { try { final qty = double.tryParse(_qtyController.text); final days = int.tryParse(_daysController.text); - await ref.read(productsProvider.notifier).update( + await ref.read(userProductsProvider.notifier).update( widget.product.id, quantity: qty, unit: _unit, diff --git a/client/lib/features/products/product_provider.dart b/client/lib/features/products/user_product_provider.dart similarity index 63% rename from client/lib/features/products/product_provider.dart rename to client/lib/features/products/user_product_provider.dart index 91cdfee..6b3ab4b 100644 --- a/client/lib/features/products/product_provider.dart +++ b/client/lib/features/products/user_product_provider.dart @@ -1,33 +1,35 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/auth/auth_provider.dart'; -import '../../shared/models/product.dart'; -import 'product_service.dart'; +import '../../shared/models/user_product.dart'; +import 'user_product_service.dart'; // --------------------------------------------------------------------------- // Providers // --------------------------------------------------------------------------- -final productServiceProvider = Provider((ref) { - return ProductService(ref.read(apiClientProvider)); +final userProductServiceProvider = Provider((ref) { + return UserProductService(ref.read(apiClientProvider)); }); -final productsProvider = - StateNotifierProvider>>((ref) { - final service = ref.read(productServiceProvider); - return ProductsNotifier(service); +final userProductsProvider = + StateNotifierProvider>>( + (ref) { + final service = ref.read(userProductServiceProvider); + return UserProductsNotifier(service); }); // --------------------------------------------------------------------------- // Notifier // --------------------------------------------------------------------------- -class ProductsNotifier extends StateNotifier>> { - ProductsNotifier(this._service) : super(const AsyncValue.loading()) { +class UserProductsNotifier + extends StateNotifier>> { + UserProductsNotifier(this._service) : super(const AsyncValue.loading()) { _load(); } - final ProductService _service; + final UserProductService _service; Future _load() async { state = const AsyncValue.loading(); @@ -43,18 +45,18 @@ class ProductsNotifier extends StateNotifier>> { required String unit, String? category, int storageDays = 7, - String? mappingId, + String? primaryProductId, }) async { - final p = await _service.createProduct( + final userProduct = await _service.createProduct( name: name, quantity: quantity, unit: unit, category: category, storageDays: storageDays, - mappingId: mappingId, + primaryProductId: primaryProductId, ); state.whenData((products) { - final updated = [...products, p] + final updated = [...products, userProduct] ..sort((a, b) => a.expiresAt.compareTo(b.expiresAt)); state = AsyncValue.data(updated); }); @@ -69,7 +71,7 @@ class ProductsNotifier extends StateNotifier>> { String? category, int? storageDays, }) async { - final p = await _service.updateProduct( + final updated = await _service.updateProduct( id, name: name, quantity: quantity, @@ -78,9 +80,9 @@ class ProductsNotifier extends StateNotifier>> { storageDays: storageDays, ); 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)); - state = AsyncValue.data(updated); + state = AsyncValue.data(updatedList); }); } @@ -88,7 +90,8 @@ class ProductsNotifier extends StateNotifier>> { Future delete(String id) async { final previous = state; state.whenData((products) { - state = AsyncValue.data(products.where((p) => p.id != id).toList()); + state = + AsyncValue.data(products.where((userProduct) => userProduct.id != id).toList()); }); try { await _service.deleteProduct(id); diff --git a/client/lib/features/products/user_product_service.dart b/client/lib/features/products/user_product_service.dart new file mode 100644 index 0000000..48f9a86 --- /dev/null +++ b/client/lib/features/products/user_product_service.dart @@ -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> getProducts() async { + final list = await _client.getList('/user-products'); + return list + .map((e) => UserProduct.fromJson(e as Map)) + .toList(); + } + + Future 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 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 deleteProduct(String id) => + _client.deleteVoid('/user-products/$id'); + + Future> 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)) + .toList(); + } + + Future getByBarcode(String barcode) async { + try { + final data = await _client.get('/products/barcode/$barcode'); + return CatalogProduct.fromJson(data); + } catch (_) { + return null; + } + } +} diff --git a/client/lib/features/scan/recognition_confirm_screen.dart b/client/lib/features/scan/recognition_confirm_screen.dart index 679ae9f..669d39d 100644 --- a/client/lib/features/scan/recognition_confirm_screen.dart +++ b/client/lib/features/scan/recognition_confirm_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/locale/unit_provider.dart'; -import '../products/product_provider.dart'; +import '../products/user_product_provider.dart'; import 'recognition_service.dart'; /// Editable confirmation screen shown after receipt/products recognition. @@ -31,7 +31,7 @@ class _RecognitionConfirmScreenState quantity: item.quantity, unit: item.unit, category: item.category, - mappingId: item.mappingId, + primaryProductId: item.primaryProductId, storageDays: item.storageDays, confidence: item.confidence, )) @@ -83,13 +83,13 @@ class _RecognitionConfirmScreenState setState(() => _saving = true); try { for (final item in _items) { - await ref.read(productsProvider.notifier).create( + await ref.read(userProductsProvider.notifier).create( name: item.name, quantity: item.quantity, unit: item.unit, category: item.category, storageDays: item.storageDays, - mappingId: item.mappingId, + primaryProductId: item.primaryProductId, ); } if (mounted) { @@ -123,7 +123,7 @@ class _EditableItem { double quantity; String unit; final String category; - final String? mappingId; + final String? primaryProductId; final int storageDays; final double confidence; @@ -132,7 +132,7 @@ class _EditableItem { required this.quantity, required this.unit, required this.category, - this.mappingId, + this.primaryProductId, required this.storageDays, required this.confidence, }); diff --git a/client/lib/features/scan/recognition_service.dart b/client/lib/features/scan/recognition_service.dart index 1fdbf0f..8031755 100644 --- a/client/lib/features/scan/recognition_service.dart +++ b/client/lib/features/scan/recognition_service.dart @@ -22,7 +22,7 @@ class RecognizedItem { String unit; final String category; final double confidence; - final String? mappingId; + final String? primaryProductId; final int storageDays; RecognizedItem({ @@ -31,7 +31,7 @@ class RecognizedItem { required this.unit, required this.category, required this.confidence, - this.mappingId, + this.primaryProductId, required this.storageDays, }); @@ -42,7 +42,7 @@ class RecognizedItem { unit: json['unit'] as String? ?? 'pcs', category: json['category'] as String? ?? 'other', 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, ); } diff --git a/client/lib/l10n/app_ar.arb b/client/lib/l10n/app_ar.arb index c18cb71..400d911 100644 --- a/client/lib/l10n/app_ar.arb +++ b/client/lib/l10n/app_ar.arb @@ -28,7 +28,9 @@ "queuePosition": "الموضع {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "جارٍ المعالجة...", @@ -102,5 +104,11 @@ "photoReceipt": "تصوير الإيصال", "photoReceiptSubtitle": "التعرف على جميع المنتجات من الإيصال", "photoProducts": "تصوير المنتجات", - "photoProductsSubtitle": "الثلاجة، الطاولة، الرف — حتى 3 صور" + "photoProductsSubtitle": "الثلاجة، الطاولة، الرف — حتى 3 صور", + "addPackagedFood": "إضافة منتج معبأ", + "scanBarcode": "مسح الباركود", + "portionWeightG": "وزن الحصة (جم)", + "productNotFound": "المنتج غير موجود", + "enterManually": "أدخل يدوياً", + "perHundredG": "لكل 100 جم" } diff --git a/client/lib/l10n/app_de.arb b/client/lib/l10n/app_de.arb index 351b99a..d2d0f35 100644 --- a/client/lib/l10n/app_de.arb +++ b/client/lib/l10n/app_de.arb @@ -28,7 +28,9 @@ "queuePosition": "Position {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "Verarbeitung...", @@ -102,5 +104,11 @@ "photoReceipt": "Kassenbon fotografieren", "photoReceiptSubtitle": "Alle Produkte vom Kassenbon erkennen", "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" } diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index bf75ed5..01bbd51 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -102,5 +102,11 @@ "photoReceipt": "Photo of receipt", "photoReceiptSubtitle": "Recognize all items from a receipt", "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" } diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index fc1744e..a44c94f 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -28,7 +28,9 @@ "queuePosition": "Posición {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "Procesando...", @@ -102,5 +104,11 @@ "photoReceipt": "Fotografiar recibo", "photoReceiptSubtitle": "Reconocemos todos los productos del recibo", "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" } diff --git a/client/lib/l10n/app_fr.arb b/client/lib/l10n/app_fr.arb index 9ba1c09..6c56742 100644 --- a/client/lib/l10n/app_fr.arb +++ b/client/lib/l10n/app_fr.arb @@ -28,7 +28,9 @@ "queuePosition": "Position {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "Traitement...", @@ -102,5 +104,11 @@ "photoReceipt": "Photographier le ticket", "photoReceiptSubtitle": "Reconnaissance de tous les produits du ticket", "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" } diff --git a/client/lib/l10n/app_hi.arb b/client/lib/l10n/app_hi.arb index 0260592..6020e0b 100644 --- a/client/lib/l10n/app_hi.arb +++ b/client/lib/l10n/app_hi.arb @@ -28,7 +28,9 @@ "queuePosition": "स्थिति {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "प्रसंस्करण हो रहा है...", @@ -102,5 +104,11 @@ "photoReceipt": "रसीद की फ़ोटो", "photoReceiptSubtitle": "रसीद से सभी उत्पाद पहचानें", "photoProducts": "उत्पादों की फ़ोटो", - "photoProductsSubtitle": "फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक" + "photoProductsSubtitle": "फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक", + "addPackagedFood": "पैकेज्ड फूड जोड़ें", + "scanBarcode": "बारकोड स्कैन करें", + "portionWeightG": "हिस्से का वजन (ग्राम)", + "productNotFound": "उत्पाद नहीं मिला", + "enterManually": "मैन्युअल दर्ज करें", + "perHundredG": "प्रति 100 ग्राम" } diff --git a/client/lib/l10n/app_it.arb b/client/lib/l10n/app_it.arb index 5a30d36..a6bd019 100644 --- a/client/lib/l10n/app_it.arb +++ b/client/lib/l10n/app_it.arb @@ -28,7 +28,9 @@ "queuePosition": "Posizione {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "Elaborazione...", @@ -102,5 +104,11 @@ "photoReceipt": "Fotografa scontrino", "photoReceiptSubtitle": "Riconosciamo tutti i prodotti dallo scontrino", "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" } diff --git a/client/lib/l10n/app_ja.arb b/client/lib/l10n/app_ja.arb index c6e29ca..fb0b429 100644 --- a/client/lib/l10n/app_ja.arb +++ b/client/lib/l10n/app_ja.arb @@ -28,7 +28,9 @@ "queuePosition": "{position}番目", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "処理中...", @@ -102,5 +104,11 @@ "photoReceipt": "レシートを撮影", "photoReceiptSubtitle": "レシートから全商品を認識", "photoProducts": "食品を撮影", - "photoProductsSubtitle": "冷蔵庫・テーブル・棚 — 最大3枚" + "photoProductsSubtitle": "冷蔵庫・テーブル・棚 — 最大3枚", + "addPackagedFood": "パッケージ食品を追加", + "scanBarcode": "バーコードをスキャン", + "portionWeightG": "1食分の重さ(g)", + "productNotFound": "商品が見つかりません", + "enterManually": "手動で入力", + "perHundredG": "100gあたり" } diff --git a/client/lib/l10n/app_ko.arb b/client/lib/l10n/app_ko.arb index b3b3137..daccb51 100644 --- a/client/lib/l10n/app_ko.arb +++ b/client/lib/l10n/app_ko.arb @@ -28,7 +28,9 @@ "queuePosition": "{position}번째", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "처리 중...", @@ -102,5 +104,11 @@ "photoReceipt": "영수증 촬영", "photoReceiptSubtitle": "영수증의 모든 상품 인식", "photoProducts": "식품 촬영", - "photoProductsSubtitle": "냉장고, 테이블, 선반 — 최대 3장" + "photoProductsSubtitle": "냉장고, 테이블, 선반 — 최대 3장", + "addPackagedFood": "포장 식품 추가", + "scanBarcode": "바코드 스캔", + "portionWeightG": "1회 제공량 (g)", + "productNotFound": "제품을 찾을 수 없습니다", + "enterManually": "직접 입력", + "perHundredG": "100g당" } diff --git a/client/lib/l10n/app_localizations.dart b/client/lib/l10n/app_localizations.dart index 8fe99f7..7a7f7f0 100644 --- a/client/lib/l10n/app_localizations.dart +++ b/client/lib/l10n/app_localizations.dart @@ -705,6 +705,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Fridge, table, shelf — up to 3 photos'** 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 diff --git a/client/lib/l10n/app_localizations_ar.dart b/client/lib/l10n/app_localizations_ar.dart index 531544a..8427aaa 100644 --- a/client/lib/l10n/app_localizations_ar.dart +++ b/client/lib/l10n/app_localizations_ar.dart @@ -304,4 +304,22 @@ class AppLocalizationsAr extends AppLocalizations { @override 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 جم'; } diff --git a/client/lib/l10n/app_localizations_de.dart b/client/lib/l10n/app_localizations_de.dart index 476231b..1d63c23 100644 --- a/client/lib/l10n/app_localizations_de.dart +++ b/client/lib/l10n/app_localizations_de.dart @@ -306,4 +306,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String get photoProductsSubtitle => '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'; } diff --git a/client/lib/l10n/app_localizations_en.dart b/client/lib/l10n/app_localizations_en.dart index b0b447f..02b25df 100644 --- a/client/lib/l10n/app_localizations_en.dart +++ b/client/lib/l10n/app_localizations_en.dart @@ -304,4 +304,22 @@ class AppLocalizationsEn extends AppLocalizations { @override 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'; } diff --git a/client/lib/l10n/app_localizations_es.dart b/client/lib/l10n/app_localizations_es.dart index db21684..ff5dc77 100644 --- a/client/lib/l10n/app_localizations_es.dart +++ b/client/lib/l10n/app_localizations_es.dart @@ -306,4 +306,22 @@ class AppLocalizationsEs extends AppLocalizations { @override 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'; } diff --git a/client/lib/l10n/app_localizations_fr.dart b/client/lib/l10n/app_localizations_fr.dart index 6c26467..a207700 100644 --- a/client/lib/l10n/app_localizations_fr.dart +++ b/client/lib/l10n/app_localizations_fr.dart @@ -307,4 +307,22 @@ class AppLocalizationsFr extends AppLocalizations { @override String get photoProductsSubtitle => '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'; } diff --git a/client/lib/l10n/app_localizations_hi.dart b/client/lib/l10n/app_localizations_hi.dart index 34e4b9c..cf378fb 100644 --- a/client/lib/l10n/app_localizations_hi.dart +++ b/client/lib/l10n/app_localizations_hi.dart @@ -305,4 +305,22 @@ class AppLocalizationsHi extends AppLocalizations { @override 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 ग्राम'; } diff --git a/client/lib/l10n/app_localizations_it.dart b/client/lib/l10n/app_localizations_it.dart index 5b5f622..d3c2f5f 100644 --- a/client/lib/l10n/app_localizations_it.dart +++ b/client/lib/l10n/app_localizations_it.dart @@ -306,4 +306,22 @@ class AppLocalizationsIt extends AppLocalizations { @override 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'; } diff --git a/client/lib/l10n/app_localizations_ja.dart b/client/lib/l10n/app_localizations_ja.dart index 90b38e2..6c2a382 100644 --- a/client/lib/l10n/app_localizations_ja.dart +++ b/client/lib/l10n/app_localizations_ja.dart @@ -303,4 +303,22 @@ class AppLocalizationsJa extends AppLocalizations { @override 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あたり'; } diff --git a/client/lib/l10n/app_localizations_ko.dart b/client/lib/l10n/app_localizations_ko.dart index 9d0d94b..e325378 100644 --- a/client/lib/l10n/app_localizations_ko.dart +++ b/client/lib/l10n/app_localizations_ko.dart @@ -303,4 +303,22 @@ class AppLocalizationsKo extends AppLocalizations { @override 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당'; } diff --git a/client/lib/l10n/app_localizations_pt.dart b/client/lib/l10n/app_localizations_pt.dart index b4151e7..d22b8db 100644 --- a/client/lib/l10n/app_localizations_pt.dart +++ b/client/lib/l10n/app_localizations_pt.dart @@ -306,4 +306,22 @@ class AppLocalizationsPt extends AppLocalizations { @override String get photoProductsSubtitle => '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'; } diff --git a/client/lib/l10n/app_localizations_ru.dart b/client/lib/l10n/app_localizations_ru.dart index c8af774..d98ad57 100644 --- a/client/lib/l10n/app_localizations_ru.dart +++ b/client/lib/l10n/app_localizations_ru.dart @@ -304,4 +304,22 @@ class AppLocalizationsRu extends AppLocalizations { @override 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 г'; } diff --git a/client/lib/l10n/app_localizations_zh.dart b/client/lib/l10n/app_localizations_zh.dart index 66df297..063f445 100644 --- a/client/lib/l10n/app_localizations_zh.dart +++ b/client/lib/l10n/app_localizations_zh.dart @@ -303,4 +303,22 @@ class AppLocalizationsZh extends AppLocalizations { @override 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克'; } diff --git a/client/lib/l10n/app_pt.arb b/client/lib/l10n/app_pt.arb index b975d54..6f33559 100644 --- a/client/lib/l10n/app_pt.arb +++ b/client/lib/l10n/app_pt.arb @@ -28,7 +28,9 @@ "queuePosition": "Posição {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "Processando...", @@ -102,5 +104,11 @@ "photoReceipt": "Fotografar recibo", "photoReceiptSubtitle": "Reconhecemos todos os produtos do recibo", "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" } diff --git a/client/lib/l10n/app_ru.arb b/client/lib/l10n/app_ru.arb index ee21733..0c013dd 100644 --- a/client/lib/l10n/app_ru.arb +++ b/client/lib/l10n/app_ru.arb @@ -102,5 +102,11 @@ "photoReceipt": "Сфотографировать чек", "photoReceiptSubtitle": "Распознаем все продукты из чека", "photoProducts": "Сфотографировать продукты", - "photoProductsSubtitle": "Холодильник, стол, полка — до 3 фото" + "photoProductsSubtitle": "Холодильник, стол, полка — до 3 фото", + "addPackagedFood": "Добавить готовый продукт", + "scanBarcode": "Сканировать штрихкод", + "portionWeightG": "Вес порции (г)", + "productNotFound": "Продукт не найден", + "enterManually": "Ввести вручную", + "perHundredG": "на 100 г" } diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index d3e1cb1..56aa0dc 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -28,7 +28,9 @@ "queuePosition": "位置 {position}", "@queuePosition": { "placeholders": { - "position": { "type": "int" } + "position": { + "type": "int" + } } }, "processing": "处理中...", @@ -102,5 +104,11 @@ "photoReceipt": "拍摄收据", "photoReceiptSubtitle": "识别收据中的所有商品", "photoProducts": "拍摄食品", - "photoProductsSubtitle": "冰箱、桌子、货架 — 最多3张照片" + "photoProductsSubtitle": "冰箱、桌子、货架 — 最多3张照片", + "addPackagedFood": "添加包装食品", + "scanBarcode": "扫描条形码", + "portionWeightG": "份量(克)", + "productNotFound": "未找到产品", + "enterManually": "手动输入", + "perHundredG": "每100克" } diff --git a/client/lib/shared/models/ingredient_mapping.dart b/client/lib/shared/models/ingredient_mapping.dart deleted file mode 100644 index 6500f22..0000000 --- a/client/lib/shared/models/ingredient_mapping.dart +++ /dev/null @@ -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 json) => - _$IngredientMappingFromJson(json); - - Map toJson() => _$IngredientMappingToJson(this); -} diff --git a/client/lib/shared/models/ingredient_mapping.g.dart b/client/lib/shared/models/ingredient_mapping.g.dart deleted file mode 100644 index ddfb161..0000000 --- a/client/lib/shared/models/ingredient_mapping.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'ingredient_mapping.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -IngredientMapping _$IngredientMappingFromJson(Map 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 _$IngredientMappingToJson(IngredientMapping instance) => - { - 'id': instance.id, - 'canonical_name': instance.canonicalName, - 'category_name': instance.categoryName, - 'category': instance.category, - 'default_unit': instance.defaultUnit, - 'storage_days': instance.storageDays, - }; diff --git a/client/lib/shared/models/product.dart b/client/lib/shared/models/product.dart index d4a1a5d..8efc98e 100644 --- a/client/lib/shared/models/product.dart +++ b/client/lib/shared/models/product.dart @@ -2,45 +2,48 @@ import 'package:json_annotation/json_annotation.dart'; part 'product.g.dart'; +/// Catalog product (shared nutrition database entry). @JsonSerializable() -class Product { +class CatalogProduct { final String id; - @JsonKey(name: 'user_id') - final String userId; - @JsonKey(name: 'mapping_id') - final String? mappingId; - final String name; - final double quantity; - final String unit; + @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; - @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; + final int? storageDays; + final String? barcode; + @JsonKey(name: 'calories_per_100g') + final double? caloriesPer100g; + @JsonKey(name: 'protein_per_100g') + final double? proteinPer100g; + @JsonKey(name: 'fat_per_100g') + final double? fatPer100g; + @JsonKey(name: 'carbs_per_100g') + final double? carbsPer100g; - const Product({ + const CatalogProduct({ required this.id, - required this.userId, - this.mappingId, - required this.name, - required this.quantity, - required this.unit, + required this.canonicalName, + this.categoryName, this.category, - required this.storageDays, - required this.addedAt, - required this.expiresAt, - required this.daysLeft, - required this.expiringSoon, + this.defaultUnit, + this.storageDays, + this.barcode, + this.caloriesPer100g, + this.proteinPer100g, + this.fatPer100g, + this.carbsPer100g, }); - factory Product.fromJson(Map json) => - _$ProductFromJson(json); + /// Display name is the server-resolved canonical name (language-aware from backend). + String get displayName => canonicalName; - Map toJson() => _$ProductToJson(this); + factory CatalogProduct.fromJson(Map json) => + _$CatalogProductFromJson(json); + + Map toJson() => _$CatalogProductToJson(this); } diff --git a/client/lib/shared/models/product.g.dart b/client/lib/shared/models/product.g.dart index 0b5ebeb..4224401 100644 --- a/client/lib/shared/models/product.g.dart +++ b/client/lib/shared/models/product.g.dart @@ -6,32 +6,32 @@ part of 'product.dart'; // JsonSerializableGenerator // ************************************************************************** -Product _$ProductFromJson(Map json) => Product( - id: json['id'] as String, - userId: json['user_id'] as String, - mappingId: json['mapping_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, -); +CatalogProduct _$CatalogProductFromJson(Map json) => + CatalogProduct( + 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(), + barcode: json['barcode'] as String?, + caloriesPer100g: (json['calories_per_100g'] as num?)?.toDouble(), + proteinPer100g: (json['protein_per_100g'] as num?)?.toDouble(), + fatPer100g: (json['fat_per_100g'] as num?)?.toDouble(), + carbsPer100g: (json['carbs_per_100g'] as num?)?.toDouble(), + ); -Map _$ProductToJson(Product instance) => { - 'id': instance.id, - 'user_id': instance.userId, - 'mapping_id': instance.mappingId, - '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, -}; +Map _$CatalogProductToJson(CatalogProduct instance) => + { + 'id': instance.id, + 'canonical_name': instance.canonicalName, + 'category_name': instance.categoryName, + 'category': instance.category, + 'default_unit': instance.defaultUnit, + 'storage_days': instance.storageDays, + 'barcode': instance.barcode, + 'calories_per_100g': instance.caloriesPer100g, + 'protein_per_100g': instance.proteinPer100g, + 'fat_per_100g': instance.fatPer100g, + 'carbs_per_100g': instance.carbsPer100g, + }; diff --git a/client/lib/shared/models/user_product.dart b/client/lib/shared/models/user_product.dart new file mode 100644 index 0000000..c738c84 --- /dev/null +++ b/client/lib/shared/models/user_product.dart @@ -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 json) => + _$UserProductFromJson(json); + + Map toJson() => _$UserProductToJson(this); +} diff --git a/client/lib/shared/models/user_product.g.dart b/client/lib/shared/models/user_product.g.dart new file mode 100644 index 0000000..459581b --- /dev/null +++ b/client/lib/shared/models/user_product.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_product.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UserProduct _$UserProductFromJson(Map 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 _$UserProductToJson(UserProduct instance) => + { + '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, + }; diff --git a/client/pubspec.lock b/client/pubspec.lock index 7007bd1..a2acdd5 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -717,6 +717,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 9df4d46..d8fcb80 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: # Camera / gallery image_picker: ^1.1.0 + mobile_scanner: ^6.0.0 dev_dependencies: flutter_test: