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