// Package adminstore is the read/write data-access layer for the admin console. // Unlike the public store (read-only), it performs INSERT/UPDATE/DELETE and // records field-level provenance (source = "manual") plus an audit_log entry // for every write, then recomputes product.quality_score. package adminstore import ( "context" "encoding/json" "errors" "strconv" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // ErrNotFound is returned when a requested row does not exist. var ErrNotFound = errors.New("not found") // Store wraps a pgx pool for admin operations. type Store struct { pool *pgxpool.Pool } // New constructs an admin Store. func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // Ping verifies DB connectivity. func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } // CompletenessFields mirrors ingestion/opengoods/etl/quality.py COMPLETENESS_FIELDS. var CompletenessFields = []string{ "name", "gtin", "brand", "category", "net_content", "country_of_origin", "nutriments", "ingredients", "image", } // ---------- list ---------- // ProductRow is a list-view row for the admin product table. type ProductRow struct { ID string `json:"id"` GTIN *string `json:"gtin"` Name string `json:"name"` Brand *string `json:"brand"` CategoryPath *string `json:"category_path"` Status string `json:"status"` QualityScore float64 `json:"quality_score"` Missing []string `json:"missing"` UpdatedAt string `json:"updated_at"` } // ListProducts returns a paginated, optionally name/gtin-filtered list. func (s *Store) ListProducts(ctx context.Context, q string, limit, offset int) ([]ProductRow, int, error) { args := []any{} where := "WHERE 1=1" if q != "" { args = append(args, q) where += " AND (p.name ILIKE '%' || $1 || '%' OR p.gtin ILIKE '%' || $1 || '%')" } var total int if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM product p "+where, args...).Scan(&total); err != nil { return nil, 0, err } args = append(args, limit, offset) sql := ` SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.status, p.quality_score, p.updated_at, (p.brand_id IS NOT NULL) AS has_brand, (p.category_id IS NOT NULL) AS has_cat, (p.net_content_canonical IS NOT NULL) AS has_net, (p.country_of_origin IS NOT NULL AND p.country_of_origin <> '') AS has_country, (f.nutriments IS NOT NULL AND f.nutriments::text <> '{}') AS has_nutri, (f.ingredients_text IS NOT NULL AND f.ingredients_text <> '') AS has_ing, EXISTS (SELECT 1 FROM product_image pi WHERE pi.product_id = p.id) AS has_img FROM product p LEFT JOIN brand b ON b.id = p.brand_id LEFT JOIN category c ON c.id = p.category_id LEFT JOIN food_detail f ON f.product_id = p.id ` + where + " ORDER BY p.updated_at DESC LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args)) rows, err := s.pool.Query(ctx, sql, args...) if err != nil { return nil, 0, err } defer rows.Close() out := []ProductRow{} for rows.Next() { var r ProductRow var hasBrand, hasCat, hasNet, hasCountry, hasNutri, hasIng, hasImg bool var updated time.Time if err := rows.Scan(&r.ID, &r.GTIN, &r.Name, &r.Brand, &r.CategoryPath, &r.Status, &r.QualityScore, &updated, &hasBrand, &hasCat, &hasNet, &hasCountry, &hasNutri, &hasIng, &hasImg); err != nil { return nil, 0, err } r.UpdatedAt = updated.Format(time.RFC3339) present := map[string]bool{ "name": r.Name != "", "gtin": r.GTIN != nil && *r.GTIN != "", "brand": hasBrand, "category": hasCat, "net_content": hasNet, "country_of_origin": hasCountry, "nutriments": hasNutri, "ingredients": hasIng, "image": hasImg, } r.Missing = []string{} for _, f := range CompletenessFields { if !present[f] { r.Missing = append(r.Missing, f) } } out = append(out, r) } return out, total, rows.Err() } // ---------- detail ---------- // ProductImage is one image row. type ProductImage struct { ID string `json:"id"` URL string `json:"url"` Kind string `json:"kind"` License *string `json:"license"` } // MSRP is one suggested-retail-price snapshot. type MSRP struct { ID string `json:"id"` 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"` } // ProductDetail is the full editable view of a product. type ProductDetail struct { ID string `json:"id"` GTIN *string `json:"gtin"` Name string `json:"name"` BrandID *string `json:"brand_id"` Brand *string `json:"brand"` CategoryID *string `json:"category_id"` CategoryPath *string `json:"category_path"` NetContentValue *float64 `json:"net_content_value"` NetContentUnit *string `json:"net_content_unit"` CountryOfOrigin *string `json:"country_of_origin"` Status string `json:"status"` QualityScore float64 `json:"quality_score"` 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"` Barcodes []Barcode `json:"barcodes"` Images []ProductImage `json:"images"` MSRP []MSRP `json:"msrp"` Missing []string `json:"missing"` UpdatedAt string `json:"updated_at"` } // GetProduct returns the full editable detail for one product. func (s *Store) GetProduct(ctx context.Context, id string) (*ProductDetail, error) { var d ProductDetail var nutriments []byte var updated time.Time err := s.pool.QueryRow(ctx, ` SELECT p.id, p.gtin, p.name, p.brand_id, b.name, p.category_id, c.path::text, p.net_content_value, p.net_content_unit, p.country_of_origin, p.status, p.quality_score, p.updated_at, f.ingredients_text, f.allergens, f.additives, f.nutriments, f.nutrition_basis, f.serving_size, f.nutri_score FROM product p LEFT JOIN brand b ON b.id = p.brand_id LEFT JOIN category c ON c.id = p.category_id LEFT JOIN food_detail f ON f.product_id = p.id WHERE p.id = $1`, id).Scan( &d.ID, &d.GTIN, &d.Name, &d.BrandID, &d.Brand, &d.CategoryID, &d.CategoryPath, &d.NetContentValue, &d.NetContentUnit, &d.CountryOfOrigin, &d.Status, &d.QualityScore, &updated, &d.IngredientsText, &d.Allergens, &d.Additives, &nutriments, &d.NutritionBasis, &d.ServingSize, &d.NutriScore, ) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } d.UpdatedAt = updated.Format(time.RFC3339) if len(nutriments) > 0 { _ = json.Unmarshal(nutriments, &d.Nutriments) } if d.Allergens == nil { d.Allergens = []string{} } if d.Additives == nil { d.Additives = []string{} } bcs, err := s.listBarcodes(ctx, id) if err != nil { return nil, err } d.Barcodes = bcs imgs, err := s.listImages(ctx, id) if err != nil { return nil, err } d.Images = imgs msrps, err := s.listMSRP(ctx, id) if err != nil { return nil, err } d.MSRP = msrps d.Missing = missingFromDetail(&d) return &d, nil } func missingFromDetail(d *ProductDetail) []string { present := map[string]bool{ "name": d.Name != "", "gtin": d.GTIN != nil && *d.GTIN != "", "brand": d.BrandID != nil, "category": d.CategoryID != nil, "net_content": d.NetContentValue != nil, "country_of_origin": d.CountryOfOrigin != nil && *d.CountryOfOrigin != "", "nutriments": len(d.Nutriments) > 0, "ingredients": d.IngredientsText != nil && *d.IngredientsText != "", "image": len(d.Images) > 0, } missing := []string{} for _, f := range CompletenessFields { if !present[f] { missing = append(missing, f) } } return missing } func (s *Store) listImages(ctx context.Context, productID string) ([]ProductImage, error) { rows, err := s.pool.Query(ctx, "SELECT id, url, kind, license FROM product_image WHERE product_id = $1 ORDER BY id", productID) if err != nil { return nil, err } defer rows.Close() out := []ProductImage{} for rows.Next() { var im ProductImage if err := rows.Scan(&im.ID, &im.URL, &im.Kind, &im.License); err != nil { return nil, err } out = append(out, im) } return out, rows.Err() } func (s *Store) listMSRP(ctx context.Context, productID string) ([]MSRP, error) { rows, err := s.pool.Query(ctx, ` SELECT id, amount, currency, region, effective_date::text, source_url, note FROM product_msrp WHERE product_id = $1 ORDER BY effective_date DESC NULLS LAST`, productID) if err != nil { return nil, err } defer rows.Close() out := []MSRP{} for rows.Next() { var m MSRP if err := rows.Scan(&m.ID, &m.Amount, &m.Currency, &m.Region, &m.EffectiveDate, &m.SourceURL, &m.Note); err != nil { return nil, err } out = append(out, m) } return out, rows.Err() }