package services import ( "encoding/json" "fmt" "io" "log" "math" "net/http" "net/url" "nearle/models" "regexp" "sort" "strings" "sync" "time" ) /* Nutrition for the customer app's product screen. ── Where the figures come from ───────────────────────────────────────────── `mcp.nearle.ai.in` — the catalogue-intelligence service, the same one that scrapes the global catalogue and backs the health score card in the console. Not Fiesta's own database: nothing on `products` has ever held nutrition, and the service already has it keyed by brand and image_id, which is exactly what a tenant's product carries from import. So this reads it rather than copying it. A second store of the same figures is a second thing to keep in step, and the one that drifts is the one on a food label. ── Why the console does this in the browser and the app cannot ───────────── The console calls the service directly from `api/nutrition.ts`. The customer app could too, in principle, and should not have to: it would mean a second base URL, a second failure mode and the brand-spelling problem below reimplemented in whatever the app is written in. The product screen already calls Fiesta, so Fiesta answers the whole question. */ // NutritionSource is the shape the catalogue-intelligence service answers with. // // Only the fields the panel needs. The service returns roughly forty, including // scores, insights, allergens and diet tags — all of which belong to the health // score card in the console and none of which the app asked for. type NutritionSource struct { // "verified" when the source record was confirmed, "unavailable" when the // service knows the product and has nothing for it. DataStatus string `json:"data_status"` // The health score half of the same record. HealthScore *float64 `json:"health_score"` // Absent from this endpoint — only the list sends it — so the band is // derived. See models.BandFor. HealthBand string `json:"health_band"` // What the service thinks the product IS. The edibility guard reads this, // and it is routinely null, which the guard treats as "not food". Category string `json:"category"` // 0–1. How sure the service is it matched the right source record. Below // models.LowConfidence the figures are a guide, not a fact. MatchConfidence *float64 `json:"match_confidence"` PositiveInsights []string `json:"positive_insights"` NutritionalCautions []string `json:"nutritional_cautions"` DietTags []string `json:"diet_tags"` Allergens []string `json:"allergens"` DataSource string `json:"data_source"` SourceURL string `json:"source_url"` ServingSizeLabel string `json:"serving_size_label"` // Per 100g, every one of them. The service's own insights say so — "6.8 g // per 100 g" — and the console prints "per 100 g" beneath the same numbers. Calories *float64 `json:"calories_kcal"` Protein *float64 `json:"protein_g"` Carbohydrates *float64 `json:"carbohydrates_g"` TotalSugar *float64 `json:"total_sugar_g"` AddedSugar *float64 `json:"added_sugar_g"` DietaryFiber *float64 `json:"dietary_fiber_g"` TotalFat *float64 `json:"total_fat_g"` SaturatedFat *float64 `json:"saturated_fat_g"` TransFat *float64 `json:"trans_fat_g"` Cholesterol *float64 `json:"cholesterol_mg"` Sodium *float64 `json:"sodium_mg"` // Whatever else the source record stated, as name → {unit, value}. The // 5 Star record carries `{"Salt": {"unit":"g","value":0.268}}`. Carried // through rather than filtered: it is label text, and this code is not the // authority on what belongs on a food label. Extended map[string]struct { Unit string `json:"unit"` Value float64 `json:"value"` } `json:"extended_nutrients"` } // Panel maps the source record onto what the app renders. // // Order is the order a label prints: energy, the macros, then what the source // added. Only fields the service actually stated — a table of dashes is worse // than a short table, and the service genuinely omits things (`added_sugar_g` // is null on the 5 Star record). // // Returns nil when nothing was stated, so "no panel" and "an empty panel" stay // different answers. func (s *NutritionSource) Panel() *models.NutritionPanel { if s == nil { return nil } rows := []struct { name string value *float64 unit string }{ {"Energy", s.Calories, "kcal"}, {"Protein", s.Protein, "g"}, {"Carbohydrate", s.Carbohydrates, "g"}, {"Total Sugars", s.TotalSugar, "g"}, {"Added Sugars", s.AddedSugar, "g"}, {"Dietary Fibre", s.DietaryFiber, "g"}, {"Total Fat", s.TotalFat, "g"}, {"Saturated Fat", s.SaturatedFat, "g"}, {"Trans Fat", s.TransFat, "g"}, {"Cholesterol", s.Cholesterol, "mg"}, {"Sodium", s.Sodium, "mg"}, } items := make([]models.NutritionItem, 0, len(rows)+len(s.Extended)) for _, row := range rows { if row.value == nil { continue } items = append(items, models.NutritionItem{ Name: row.name, Value: *row.value, Unit: row.unit, }) } // Sorted, because a Go map has no order and a nutrition panel that // reshuffles between two requests for the same product looks broken. for _, name := range sortedKeys(s.Extended) { extra := s.Extended[name] items = append(items, models.NutritionItem{ Name: name, Value: extra.Value, Unit: extra.Unit, }) } if len(items) == 0 { return nil } return &models.NutritionPanel{ Per: "100g", Servingsize: strings.TrimSpace(s.ServingSizeLabel), Items: items, } } func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } /* NutritionService fetches one product's panel. ── Everything here is about not hurting the product screen ───────────────── This runs inside `getproductbyvariant`, which a shopper is waiting on, and it calls a service Fiesta does not own. So: - a short timeout, because a slow third party must not become a slow shop; - a cache, because nutrition for a packaged product does not change during a trading day and a variant group asks for several products at once; - every failure returns nil, never an error. A product page without a nutrition panel is a product page. A product page that 500s is not. */ type NutritionService interface { // ForProduct returns the nutrition panel and the health score for one // product. Either may be nil, independently: a product can be scored with // no figures published, and carry figures with no score. // // Never returns an error: see above. ForProduct(brand, imageID string) ProductNutrition } // ProductNutrition is both halves of one product's record. // // One value because they come from ONE request. Fetching them separately would // double the traffic to a third party on a screen a shopper is waiting on, to // split a record the service returns whole. type ProductNutrition struct { Panel *models.NutritionPanel Health *models.HealthScore } // nutritionTimeout is deliberately short. // // The alternative is a shopper watching a spinner because somebody else's // service is having a bad afternoon. A missing panel costs a section of one // screen; a slow response costs the screen. const nutritionTimeout = 3 * time.Second // nutritionTTL is how long a fetched panel is reused. // // A packaged product's nutrition does not change during a trading day, and the // console's own panel uses the same reasoning. Long enough to matter, short // enough that a correction at the source reaches shoppers the same day. const nutritionTTL = 6 * time.Hour type nutritionService struct { base string client *http.Client mu sync.RWMutex cache map[string]cachedPanel brands []string // Zero until the brand list has been read once. brandsAt time.Time } type cachedPanel struct { value ProductNutrition at time.Time } // NewNutritionService builds the client, or returns nil when no base URL is set. // // Nil is a working configuration, like the mailer: a deployment without the // catalogue-intelligence service still serves every product screen, without a // nutrition panel on it. `PanelFor` is nil-safe so no caller has to check. func NewNutritionService(base string) NutritionService { base = strings.TrimRight(strings.TrimSpace(base), "/") if base == "" { return nil } service := &nutritionService{ base: base, client: &http.Client{Timeout: nutritionTimeout}, cache: map[string]cachedPanel{}, } // The brand list is warmed in the background, and this is not an // optimisation — it removes a shopper from the path of a call that has to // succeed. // // Brand case decides whether a lookup returns anything at all: measured // 30 Sep 2026, /brands answers in 0.2s to 2.3s, and a cold product lookup // otherwise pays for it before its own request. Two slow hops inside one // product screen is how a nutrition panel becomes "sometimes there". // // Nothing waits on this. A request arriving before it lands still tries, // still gets an answer, and simply does not cache it. go service.resolveBrand("") return service } func (s *nutritionService) ForProduct(brand, imageID string) ProductNutrition { if s == nil { return ProductNutrition{} } brand, imageID = strings.TrimSpace(brand), strings.TrimSpace(imageID) if brand == "" || imageID == "" { // No join key. Sheet-imported products have no image_id, and there is // nothing to look up rather than something that failed to be found. return ProductNutrition{} } key := brand + "/" + imageID s.mu.RLock() hit, ok := s.cache[key] s.mu.RUnlock() if ok && time.Since(hit.at) < nutritionTTL { return hit.value } value, resolved := s.fetch(brand, imageID) // NOT cached when the brand could not be resolved. // // Brand case is load-bearing: measured 30 Sep 2026, // `/nutrition/Balaji/balaji_..._135g` returns a 65.3 score and 545 kcal // while `/nutrition/balaji/...` — our own spelling — returns a well-formed // record with every field null. So a lookup made before the brand list // arrived does not mean "this product has no nutrition", it means we asked // the wrong question. // // Caching that answer for six hours would turn one slow moment on the // brand list into six hours of silently missing nutrition across every // product of every brand. Left uncached, the next request retries. if !resolved { return value } // A genuine miss IS cached. Most products are not scored yet, and re-asking // on every tap would mean the least useful answer costing the most requests. s.mu.Lock() s.cache[key] = cachedPanel{value: value, at: time.Now()} s.mu.Unlock() return value } // fetch returns the record, and whether the brand name it asked under was the // service's own. An unresolved brand makes the answer untrustworthy — see // ForProduct. func (s *nutritionService) fetch(brand, imageID string) (ProductNutrition, bool) { name, resolved := s.resolveBrand(brand) var source NutritionSource if !s.get(fmt.Sprintf("/nutrition/%s/%s", url.PathEscape(name), url.PathEscape(imageID)), &source) { return ProductNutrition{}, resolved } return ProductNutrition{Panel: source.Panel(), Health: source.Health()}, resolved } /* resolveBrand turns our spelling of a brand into theirs. The two catalogues agree on every brand and disagree on how to write it: ours theirs patanjali → Patanjali coca_cola → Coca-Cola underscore becomes a HYPHEN brooke_bond → Brooke Bond underscore becomes a SPACE Which separator an underscore becomes cannot be derived — hyphen for Coca-Cola and Colgate-Palmolive, space for everything else — so the list is fetched and matched on a normalised form rather than guessed at. This is not cosmetic. A wrong spelling returns a well-formed record with every figure null, which is indistinguishable from a product nobody has scored. Get it wrong and nutrition is simply absent, everywhere, forever, with nothing in any log to say why. Our own spelling is returned when theirs is unknown, so a brand they have not listed still gets a real attempt rather than being dropped. */ func (s *nutritionService) resolveBrand(raw string) (string, bool) { wanted := normaliseBrand(raw) s.mu.RLock() brands, at := s.brands, s.brandsAt s.mu.RUnlock() // Refreshed on the same clock as a panel: a brand list changes when the // scraper learns a new brand, which is not often and not urgently. if at.IsZero() || time.Since(at) > nutritionTTL { var payload struct { Brands []string `json:"brands"` } if s.get("/brands", &payload) { brands = payload.Brands s.mu.Lock() s.brands, s.brandsAt = brands, time.Now() s.mu.Unlock() } } // Called with "" by the startup warm, which wants the fetch above and // nothing else. if wanted == "" { return raw, false } for _, candidate := range brands { if normaliseBrand(candidate) == wanted { return candidate, true } } // Either the list never arrived, or they do not carry this brand. Both are // worth one attempt under our own spelling — but neither is worth caching. return raw, false } var brandNoise = regexp.MustCompile(`[^a-z0-9]`) func normaliseBrand(brand string) string { return brandNoise.ReplaceAllString(strings.ToLower(strings.TrimSpace(brand)), "") } // get reads one JSON document. Reports whether it got one. // // Every failure is a false and a log line, never an error returned upward: the // caller's job is to draw a product screen and it can do that without this. func (s *nutritionService) get(path string, into any) bool { response, err := s.client.Get(s.base + path) if err != nil { log.Printf("nutrition: %s%s: %v", s.base, path, err) return false } defer response.Body.Close() if response.StatusCode != http.StatusOK { // 404 is a normal answer here — the service does not know this product. // Logged at the same level as the rest because a sudden wall of them is // how a renamed path or a moved host gets noticed. log.Printf("nutrition: %s%s: HTTP %d", s.base, path, response.StatusCode) return false } // Capped: this is an upstream Fiesta does not control, and an unbounded // read from one is how a memory limit gets found in production. body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { log.Printf("nutrition: %s%s: %v", s.base, path, err) return false } if err := json.Unmarshal(body, into); err != nil { log.Printf("nutrition: %s%s: malformed response: %v", s.base, path, err) return false } return true } /* Health returns the score, or nil when there is nothing safe to show. ── The three ways this returns nothing ───────────────────────────────────── - the service has no score for the product, which is most of them; - the product is not food. The per-product endpoint is not gated for edibility and has rated insecticide 80/100. See models.IsEdible — the guard stays even though those records now read "unavailable", because the tenant this was built for sells soap next to its biscuits; - there is no record at all. All three render as "not scored yet", which is honest in every case. ── What it sends that the raw record does not ────────────────────────────── A band, a label, and — when the match is weak — a caveat and an allergen warning. Those are judgements, and they already exist in the console. Sending the raw number instead would mean the app re-deriving them, and two screens disagreeing about the same product. */ func (s *NutritionSource) Health() *models.HealthScore { if s == nil || s.HealthScore == nil { return nil } if !models.IsEdible(s.Category) { return nil } value := *s.HealthScore band := models.BandFor(value, s.HealthBand) // Stated plainly only when the match supports it. var caveat string if s.MatchConfidence != nil && *s.MatchConfidence < models.LowConfidence { caveat = fmt.Sprintf( "Matched to a reference product with %d%% confidence — treat these figures as a guide.", int(math.Round(*s.MatchConfidence*100))) } allergens := clean(s.Allergens) return &models.HealthScore{ Score: int(math.Round(value)), Band: band, Label: models.BandLabel(band), Positives: clean(s.PositiveInsights), Cautions: clean(s.NutritionalCautions), Diettags: clean(s.DietTags), Allergens: allergens, // An empty list is only trustworthy when the match is. Silence must not // stand in for "contains none". Allergensunconfirmed: len(allergens) == 0 && caveat != "", Caveat: caveat, Source: sourceOf(s), } } func sourceOf(s *NutritionSource) *models.HealthSource { url := strings.TrimSpace(s.SourceURL) if url == "" { return nil } label := strings.TrimSpace(s.DataSource) if label == "" { label = "source" } return &models.HealthSource{Label: label, URL: url} } // clean drops blanks, which the service sends for an empty ai_summary and // others. func clean(list []string) []string { kept := make([]string, 0, len(list)) for _, entry := range list { if trimmed := strings.TrimSpace(entry); trimmed != "" { kept = append(kept, trimmed) } } if len(kept) == 0 { return nil } return kept }