health score

This commit is contained in:
2026-09-29 22:40:36 +05:30
parent f9fb405974
commit fb859ecda1
21 changed files with 1590 additions and 703 deletions

View File

@@ -37,12 +37,7 @@ var ErrCatalogueDBUnavailable = errors.New("catalogue database is not configured
// catalogueProductColumns casts the text[] columns to text: GORM's raw
// scan-into-struct silently drops slice-kind destination fields, so they
// are read as text here and parsed into []string in scanProductRow.
//
// DELIBERATELY WITHOUT `nutrition`. This list is the fallback for when column
// discovery has not run, and it asserts that every column it names exists — so
// naming one that no brand table has yet would fail every read on this path
// rather than cost one field. `nutrition` lives in catalogueOptionalColumns
// below, which is what lets it ship before the agent team creates it.
const catalogueProductColumns = `id, product_name, title, description, category, image_id, size,
variant_key, product_sku, sku_source, price_range, providers::text AS providers, fssai_license,
highlights::text AS highlights, nutrients::text AS nutrients, search_query, created_at, updated_at`
@@ -73,12 +68,6 @@ var catalogueOptionalColumns = []struct{ Name, Present, Absent string }{
{"fssai_license", "fssai_license", "NULL::text AS fssai_license"},
{"highlights", "highlights::text AS highlights", "NULL::text AS highlights"},
{"nutrients", "nutrients::text AS nutrients", "NULL::text AS nutrients"},
// The structured panel the agent team fills: per, serving size and typed
// rows. Optional like everything else here, and that is what makes it
// shippable before the column exists — a brand table without it reads NULL
// and falls back to parsing `nutrients` above, rather than going dark.
{"nutrition", "nutrition::text AS nutrition", "NULL::text AS nutrition"},
{"search_query", "search_query", "NULL::text AS search_query"},
{"created_at", "created_at", "NULL::timestamptz AS created_at"},
{"updated_at", "updated_at", "NULL::timestamptz AS updated_at"},
@@ -146,7 +135,6 @@ type catalogueProductRow struct {
FSSAILicense string
Highlights string
Nutrients string
Nutrition string
SearchQuery string
CreatedAt time.Time
UpdatedAt time.Time
@@ -230,7 +218,6 @@ func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
FSSAILicense: row.FSSAILicense,
Highlights: models.ParsePGArray(row.Highlights),
Nutrients: models.ParsePGArray(row.Nutrients),
Nutrition: nutritionOf(row),
SearchQuery: row.SearchQuery,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
@@ -741,29 +728,3 @@ func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.Ca
product := row.toModel(strings.ToLower(brand))
return &product, nil
}
// nutritionOf decides which nutrition a catalogue row actually has.
//
// Two sources, and the structured one wins wherever it exists. The agent team
// fills `nutrition` — a JSON panel with the basis, the serving size and typed
// rows — and that is the one the app wants. Everything already in the catalogue
// has only `nutrients`, a text[] of display lines, so those are parsed into the
// same shape rather than left unusable.
//
// The derived panel is strictly worse: the strings never carried a basis, so it
// has no "per 100g" on it. It is still the difference between a product page
// with figures and an empty one, for every row on the platform today.
//
// A malformed `nutrition` value falls back rather than failing the read. One
// bad JSON blob in one row should cost that row its panel, not take out the
// brand listing it appears in.
func nutritionOf(row catalogueProductRow) *models.NutritionPanel {
if raw := strings.TrimSpace(row.Nutrition); raw != "" && raw != "null" {
var panel models.NutritionPanel
if err := json.Unmarshal([]byte(raw), &panel); err == nil && panel.HasValues() {
return &panel
}
log.Printf("catalogue: product %d has nutrition that could not be read; falling back to its nutrient lines", row.ID)
}
return models.NutritionFromLines(models.ParsePGArray(row.Nutrients))
}