// Package store is the read-only data access layer for the OpenGoods API. // It only issues SELECT queries; all writes happen in the Python ingestion path. package store import ( "context" "errors" "strconv" "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 PostgreSQL connection pool. type Store struct { pool *pgxpool.Pool } // New constructs a Store from an existing pgx pool. func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // Ping verifies database connectivity. func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } // Product is the full public view of a product. type Product struct { ID string `json:"id"` GTIN *string `json:"gtin"` Name string `json:"name"` Brand *string `json:"brand"` CategoryPath *string `json:"category_path"` GPCBrickCode *string `json:"gpc_brick_code"` NetContentValue *float64 `json:"net_content_value"` NetContentUnit *string `json:"net_content_unit"` CountryOfOrigin *string `json:"country_of_origin"` QualityScore float64 `json:"quality_score"` Nutriments map[string]any `json:"nutriments,omitempty"` NutritionBasis *string `json:"nutrition_basis,omitempty"` NutriScore *string `json:"nutri_score,omitempty"` Ingredients *string `json:"ingredients_text,omitempty"` Allergens []string `json:"allergens,omitempty"` Additives []string `json:"additives,omitempty"` } // ProductSummary is a lightweight row used in search/listing responses. type ProductSummary struct { ID string `json:"id"` GTIN *string `json:"gtin"` Name string `json:"name"` Brand *string `json:"brand"` CategoryPath *string `json:"category_path"` } const productSelect = ` SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.gpc_brick_code, p.net_content_value, p.net_content_unit, p.country_of_origin, p.quality_score, f.nutriments, f.nutrition_basis, f.nutri_score, f.ingredients_text, f.allergens, f.additives 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 ` func scanProduct(row pgx.Row) (*Product, error) { var p Product err := row.Scan( &p.ID, &p.GTIN, &p.Name, &p.Brand, &p.CategoryPath, &p.GPCBrickCode, &p.NetContentValue, &p.NetContentUnit, &p.CountryOfOrigin, &p.QualityScore, &p.Nutriments, &p.NutritionBasis, &p.NutriScore, &p.Ingredients, &p.Allergens, &p.Additives, ) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &p, nil } // ProductByGTIN looks up an active product by its barcode. func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error) { row := s.pool.QueryRow(ctx, productSelect+" WHERE p.gtin = $1 AND p.status = 'active'", gtin) return scanProduct(row) } // ProductByID looks up a product by its UUID. func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) { row := s.pool.QueryRow(ctx, productSelect+" WHERE p.id = $1", id) return scanProduct(row) } // SearchProducts performs a fuzzy name search with optional category subtree filter. func (s *Store) SearchProducts(ctx context.Context, q, category string, limit, offset int) ([]ProductSummary, int, error) { args := []any{} where := "WHERE p.status = 'active'" if q != "" { args = append(args, q) where += " AND p.name ILIKE '%' || $1 || '%'" } if category != "" { args = append(args, category) where += " AND c.path <@ $" + strconv.Itoa(len(args)) + "::ltree" } countSQL := "SELECT count(*) FROM product p LEFT JOIN category c ON c.id = p.category_id " + where var total int if err := s.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil { return nil, 0, err } args = append(args, limit, offset) listSQL := ` SELECT p.id, p.gtin, p.name, b.name, c.path::text FROM product p LEFT JOIN brand b ON b.id = p.brand_id LEFT JOIN category c ON c.id = p.category_id ` + where + " ORDER BY p.name LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args)) rows, err := s.pool.Query(ctx, listSQL, args...) if err != nil { return nil, 0, err } defer rows.Close() out := []ProductSummary{} for rows.Next() { var ps ProductSummary if err := rows.Scan(&ps.ID, &ps.GTIN, &ps.Name, &ps.Brand, &ps.CategoryPath); err != nil { return nil, 0, err } out = append(out, ps) } return out, total, rows.Err() } // Nutriments returns just the nutrition payload for a product. type Nutriments struct { ProductID string `json:"product_id"` Basis *string `json:"basis"` NutriScore *string `json:"nutri_score"` Values map[string]any `json:"values"` } // Nutriments fetches the nutrition facts of a product. func (s *Store) Nutriments(ctx context.Context, id string) (*Nutriments, error) { var n Nutriments n.ProductID = id err := s.pool.QueryRow(ctx, "SELECT nutriments, nutrition_basis, nutri_score FROM food_detail WHERE product_id = $1", id, ).Scan(&n.Values, &n.Basis, &n.NutriScore) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &n, nil } // MSRP is an official suggested retail price snapshot (never a purchase link). type MSRP 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"` } // ListMSRP returns all MSRP snapshots for a product. func (s *Store) ListMSRP(ctx context.Context, id string) ([]MSRP, error) { rows, err := s.pool.Query(ctx, `SELECT amount, currency, region, effective_date::text, source_url, note FROM product_msrp WHERE product_id = $1 ORDER BY effective_date DESC NULLS LAST`, id) if err != nil { return nil, err } defer rows.Close() out := []MSRP{} for rows.Next() { var m MSRP if err := rows.Scan(&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() } // Brand is a public brand entry. type Brand struct { ID string `json:"id"` Name string `json:"name"` } // ListBrands returns brands ordered by name. func (s *Store) ListBrands(ctx context.Context, limit, offset int) ([]Brand, int, error) { var total int if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM brand").Scan(&total); err != nil { return nil, 0, err } rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY name LIMIT $1 OFFSET $2", limit, offset) if err != nil { return nil, 0, err } defer rows.Close() out := []Brand{} for rows.Next() { var b Brand if err := rows.Scan(&b.ID, &b.Name); err != nil { return nil, 0, err } out = append(out, b) } return out, total, rows.Err() } // Category is a node in the self-built category tree. type Category struct { ID string `json:"id"` NameZH string `json:"name_zh"` NameEN *string `json:"name_en"` Path string `json:"path"` GPCBrickCode *string `json:"gpc_brick_code"` Level int `json:"level"` } // ListCategories returns the full category tree ordered by path. func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { rows, err := s.pool.Query(ctx, "SELECT id, name_zh, name_en, path::text, gpc_brick_code, 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.GPCBrickCode, &c.Level); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // Source describes a data source with its license and trust weight. type Source struct { ID string `json:"id"` Name string `json:"name"` Homepage *string `json:"homepage"` License *string `json:"license"` TrustWeight float64 `json:"trust_weight"` } // SourceByID fetches a single data source. func (s *Store) SourceByID(ctx context.Context, id string) (*Source, error) { var src Source err := s.pool.QueryRow(ctx, "SELECT id, name, homepage, license, trust_weight FROM source WHERE id = $1", id, ).Scan(&src.ID, &src.Name, &src.Homepage, &src.License, &src.TrustWeight) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &src, nil }