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

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

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

View File

@@ -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
}