283 lines
9.1 KiB
Go
283 lines
9.1 KiB
Go
// 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"`
|
|
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{}
|
|
}
|
|
|
|
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()
|
|
}
|