Files
food-ai/backend/internal/domain/product/openfoodfacts.go
dbastrikin 205edbdade 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>
2026-03-21 12:45:48 +02:00

85 lines
2.8 KiB
Go

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
}