package adminstore import ( "context" "encoding/json" "errors" "strings" "github.com/jackc/pgx/v5" ) // ProductInput is the editable payload accepted from the admin UI. type ProductInput struct { GTIN *string `json:"gtin"` Name string `json:"name"` BrandID *string `json:"brand_id"` BrandName *string `json:"brand_name"` CategoryID *string `json:"category_id"` NetContentValue *float64 `json:"net_content_value"` NetContentUnit *string `json:"net_content_unit"` CountryOfOrigin *string `json:"country_of_origin"` Status string `json:"status"` IngredientsText *string `json:"ingredients_text"` Allergens []string `json:"allergens"` Additives []string `json:"additives"` Nutriments map[string]any `json:"nutriments"` NutritionBasis *string `json:"nutrition_basis"` ServingSize *string `json:"serving_size"` NutriScore *string `json:"nutri_score"` } func normBrand(name string) string { return strings.Join(strings.Fields(strings.ToLower(name)), " ") } func (s *Store) ensureBrand(ctx context.Context, tx pgx.Tx, name string) (string, error) { var id string err := tx.QueryRow(ctx, ` INSERT INTO brand (name, normalized_name) VALUES ($1, $2) ON CONFLICT (normalized_name) DO UPDATE SET name = brand.name RETURNING id`, name, normBrand(name)).Scan(&id) return id, err } func (s *Store) manualSourceID(ctx context.Context, tx pgx.Tx) (string, error) { var id string err := tx.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&id) return id, err } // netCanonical converts value+unit to the canonical base unit via the unit table. func (s *Store) netCanonical(ctx context.Context, tx pgx.Tx, value *float64, unit *string) (*float64, error) { if value == nil || unit == nil || *unit == "" { return nil, nil } var factor *float64 err := tx.QueryRow(ctx, "SELECT to_canonical_factor FROM unit WHERE code = $1", *unit).Scan(&factor) if errors.Is(err, pgx.ErrNoRows) || factor == nil { return nil, nil } if err != nil { return nil, err } c := *value * *factor return &c, nil } // UpdateProduct applies an edit, records provenance + audit, and recomputes quality. func (s *Store) UpdateProduct(ctx context.Context, id, actor string, in ProductInput) (*ProductDetail, error) { before, err := s.GetProduct(ctx, id) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) // Resolve brand (create-by-name takes precedence over id). brandID := in.BrandID if in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "" { bid, err := s.ensureBrand(ctx, tx, strings.TrimSpace(*in.BrandName)) if err != nil { return nil, err } brandID = &bid } // Resolve category gpc brick code. var gpc *string if in.CategoryID != nil && *in.CategoryID != "" { if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, err } } canonical, err := s.netCanonical(ctx, tx, in.NetContentValue, in.NetContentUnit) if err != nil { return nil, err } status := in.Status if status == "" { status = before.Status } _, err = tx.Exec(ctx, ` UPDATE product SET gtin=$1, name=$2, brand_id=$3, category_id=$4, gpc_brick_code=$5, net_content_value=$6, net_content_unit=$7, net_content_canonical=$8, country_of_origin=$9, status=$10 WHERE id=$11`, in.GTIN, in.Name, brandID, in.CategoryID, gpc, in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, status, id) if err != nil { return nil, err } var nutriJSON []byte if in.Nutriments != nil { nutriJSON, _ = json.Marshal(in.Nutriments) } allergens := in.Allergens if allergens == nil { allergens = []string{} } additives := in.Additives if additives == nil { additives = []string{} } _, err = tx.Exec(ctx, ` INSERT INTO food_detail (product_id, ingredients_text, allergens, additives, nutriments, nutrition_basis, serving_size, nutri_score) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (product_id) DO UPDATE SET ingredients_text=EXCLUDED.ingredients_text, allergens=EXCLUDED.allergens, additives=EXCLUDED.additives, nutriments=EXCLUDED.nutriments, nutrition_basis=EXCLUDED.nutrition_basis, serving_size=EXCLUDED.serving_size, nutri_score=EXCLUDED.nutri_score`, id, in.IngredientsText, allergens, additives, nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore) if err != nil { return nil, err } if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } after, err := s.GetProduct(ctx, id) if err != nil { return nil, err } changed := diffFields(before, after) if len(changed) > 0 { if err := s.recordProvenance(ctx, id, changed); err != nil { return nil, err } } if err := s.writeAudit(ctx, actor, "update", "product", &id, changed, before, after); err != nil { return nil, err } return after, nil } func strEq(a, b *string) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return *a == *b } func floatEq(a, b *float64) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } return *a == *b } func diffFields(a, b *ProductDetail) []string { changed := []string{} add := func(name string, eq bool) { if !eq { changed = append(changed, name) } } add("gtin", strEq(a.GTIN, b.GTIN)) add("name", a.Name == b.Name) add("brand", strEq(a.BrandID, b.BrandID)) add("category", strEq(a.CategoryID, b.CategoryID)) add("net_content", floatEq(a.NetContentValue, b.NetContentValue) && strEq(a.NetContentUnit, b.NetContentUnit)) add("country_of_origin", strEq(a.CountryOfOrigin, b.CountryOfOrigin)) add("status", a.Status == b.Status) add("ingredients", strEq(a.IngredientsText, b.IngredientsText)) ja, _ := json.Marshal(a.Nutriments) jb, _ := json.Marshal(b.Nutriments) add("nutriments", string(ja) == string(jb)) add("nutrition_basis", strEq(a.NutritionBasis, b.NutritionBasis)) add("serving_size", strEq(a.ServingSize, b.ServingSize)) add("nutri_score", strEq(a.NutriScore, b.NutriScore)) return changed } func (s *Store) recordProvenance(ctx context.Context, productID string, fields []string) error { var srcID string if err := s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID); err != nil { return err } _, err := s.pool.Exec(ctx, ` INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw) VALUES ($1, $2, NULL, $3, now(), NULL)`, productID, srcID, fields) return err } func (s *Store) writeAudit(ctx context.Context, actor, action, entity string, entityID *string, fields []string, before, after any) error { bj, _ := json.Marshal(before) aj, _ := json.Marshal(after) if fields == nil { fields = []string{} } _, err := s.pool.Exec(ctx, ` INSERT INTO audit_log (actor, action, entity, entity_id, fields, before, after) VALUES ($1,$2,$3,$4,$5,$6,$7)`, actor, action, entity, entityID, fields, bj, aj) return err } // ---------- images ---------- // AddImage inserts an image URL (manual source) and recomputes quality. func (s *Store) AddImage(ctx context.Context, productID, actor, url, kind string) (*ProductImage, error) { if kind == "" { kind = "other" } var srcID string if err := s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID); err != nil { return nil, err } var im ProductImage err := s.pool.QueryRow(ctx, ` INSERT INTO product_image (product_id, url, kind, license, source_id) VALUES ($1,$2,$3,NULL,$4) RETURNING id, url, kind, license`, productID, url, kind, srcID).Scan(&im.ID, &im.URL, &im.Kind, &im.License) if err != nil { return nil, err } if _, err := s.recomputeQuality(ctx, productID); err != nil { return nil, err } _ = s.writeAudit(ctx, actor, "add_image", "product", &productID, []string{"image"}, nil, im) return &im, nil } // DeleteImage removes an image and recomputes quality. func (s *Store) DeleteImage(ctx context.Context, productID, imageID, actor string) error { ct, err := s.pool.Exec(ctx, "DELETE FROM product_image WHERE id=$1 AND product_id=$2", imageID, productID) if err != nil { return err } if ct.RowsAffected() == 0 { return ErrNotFound } if _, err := s.recomputeQuality(ctx, productID); err != nil { return err } _ = s.writeAudit(ctx, actor, "delete_image", "product", &productID, []string{"image"}, map[string]string{"image_id": imageID}, nil) return nil } // ---------- msrp ---------- // MSRPInput is the payload for adding an MSRP snapshot. type MSRPInput struct { Amount float64 `json:"amount"` Currency string `json:"currency"` Region string `json:"region"` EffectiveDate *string `json:"effective_date"` SourceURL *string `json:"source_url"` Note *string `json:"note"` } // AddMSRP inserts a suggested-retail-price snapshot. func (s *Store) AddMSRP(ctx context.Context, productID, actor string, in MSRPInput) (*MSRP, error) { if in.Currency == "" { in.Currency = "CNY" } if in.Region == "" { in.Region = "CN" } var srcID string _ = s.pool.QueryRow(ctx, "SELECT id FROM source WHERE name = 'manual'").Scan(&srcID) var m MSRP err := s.pool.QueryRow(ctx, ` INSERT INTO product_msrp (product_id, amount, currency, region, source_id, source_url, effective_date, note) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id, amount, currency, region, effective_date::text, source_url, note`, productID, in.Amount, in.Currency, in.Region, srcID, in.SourceURL, in.EffectiveDate, in.Note). Scan(&m.ID, &m.Amount, &m.Currency, &m.Region, &m.EffectiveDate, &m.SourceURL, &m.Note) if err != nil { return nil, err } _ = s.writeAudit(ctx, actor, "add_msrp", "product", &productID, []string{"msrp"}, nil, m) return &m, nil } // DeleteMSRP removes an MSRP snapshot. func (s *Store) DeleteMSRP(ctx context.Context, productID, msrpID, actor string) error { ct, err := s.pool.Exec(ctx, "DELETE FROM product_msrp WHERE id=$1 AND product_id=$2", msrpID, productID) if err != nil { return err } if ct.RowsAffected() == 0 { return ErrNotFound } _ = s.writeAudit(ctx, actor, "delete_msrp", "product", &productID, []string{"msrp"}, map[string]string{"msrp_id": msrpID}, nil) return nil } // ---------- dictionaries ---------- // Brand is a brand option for the edit form. type Brand struct { ID string `json:"id"` Name string `json:"name"` } // ListBrands returns all brands ordered by name. func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) { rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY name") if err != nil { return nil, err } defer rows.Close() out := []Brand{} for rows.Next() { var b Brand if err := rows.Scan(&b.ID, &b.Name); err != nil { return nil, err } out = append(out, b) } return out, rows.Err() } // Category is a category option for the edit form. type Category struct { ID string `json:"id"` NameZH string `json:"name_zh"` NameEN *string `json:"name_en"` Path string `json:"path"` Level int `json:"level"` } // ListCategories returns the full category tree. func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { rows, err := s.pool.Query(ctx, "SELECT id, name_zh, name_en, path::text, level FROM category ORDER BY path") if err != nil { return nil, err } defer rows.Close() out := []Category{} for rows.Next() { var c Category if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // ---------- audit ---------- // AuditEntry is one audit-log row for the history view. type AuditEntry struct { ID string `json:"id"` Actor string `json:"actor"` Action string `json:"action"` Fields []string `json:"fields"` CreatedAt string `json:"created_at"` } // ListAudit returns audit history for one product, newest first. func (s *Store) ListAudit(ctx context.Context, productID string, limit int) ([]AuditEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT id, actor, action, fields, created_at::text FROM audit_log WHERE entity='product' AND entity_id=$1 ORDER BY created_at DESC LIMIT $2`, productID, limit) if err != nil { return nil, err } defer rows.Close() out := []AuditEntry{} for rows.Next() { var e AuditEntry if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Fields, &e.CreatedAt); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() }