feat: rename ingredients→products, products→user_products; add barcode/OFF import
- Rename catalog: ingredient/* → product/* (canonical_name, barcode, nutrition per 100g)
- Rename pantry: product/* → userproduct/* (user-owned items with expiry)
- Squash migrations into single 001_initial_schema.sql (clean-db baseline)
- product_categories: add English canonical name column; fix COALESCE in queries
- Remove product_translations: product names are stored in their original language
- Add default_unit_name to product API responses via unit_translations JOIN
- Add cmd/importoff: bulk import from OpenFoodFacts JSONL dump (COPY + ON CONFLICT)
- Diary: support product_id entries alongside dish_id (CHECK num_nonnulls = 1)
- Home: getLoggedCalories joins both recipes and catalog products
- Flutter: rename models/providers/services to match backend rename
- Flutter: add barcode scan flow for diary (mobile_scanner, product_portion_sheet)
- Flutter: localise 6 new keys across 12 languages (barcode scan, portion weight)
- Routes: GET /products/search, GET /products/barcode/{barcode}, /user-products
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user