Bridge global catalogue DB to per-store product catalogue
Adds a separate CatalogueDB (pgvector) connection alongside the main nearledb, plus a new catalogue module (repository/service/controller/ routes) to browse it by brand, category, and keyword, with brand optional so the whole ~237-product catalogue can be browsed unfiltered. Adds the actual bridge: importing a catalogue product snapshots it into the tenant's own products table (keyed on brand+catalogueid, since a catalogue row's bare id is only unique within its own brand table), then links it via the existing productlocations upsert. Re-importing tops up stock and refreshes price instead of duplicating. Also adds an imported-refs endpoint so the frontend can badge already-imported items without diffing full product lists, and wires the new AWS S3 image store used to resolve catalogue product photos. Bumps Go/Docker to 1.24 for the AWS SDK dependency this needs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
299
repositories/catalogueRepository.go
Normal file
299
repositories/catalogueRepository.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"nearle/db"
|
||||
"nearle/models"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// catalogueBrandTables is the allowlist mapping a brand query param to its
|
||||
// table name in the catalogue DB. Table names cannot be parameterized in SQL,
|
||||
// so every table this package ever touches must come from this fixed map.
|
||||
var catalogueBrandTables = map[string]string{
|
||||
"dabur": "brand_dabur",
|
||||
"manna": "brand_manna",
|
||||
"naga": "brand_naga",
|
||||
"nestle": "brand_nestle",
|
||||
"pepsico": "brand_pepsico",
|
||||
"sakthi": "brand_sakthi",
|
||||
}
|
||||
|
||||
var ErrUnknownBrand = errors.New("unknown brand")
|
||||
|
||||
// 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.
|
||||
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`
|
||||
|
||||
// catalogueProductRow mirrors catalogueProductColumns for scanning; array
|
||||
// columns land here as their raw Postgres text[] literal.
|
||||
type catalogueProductRow struct {
|
||||
ID int64
|
||||
ProductName string
|
||||
Title string
|
||||
Description string
|
||||
Category string
|
||||
ImageID string
|
||||
Size string
|
||||
VariantKey string
|
||||
ProductSKU string
|
||||
SKUSource string
|
||||
PriceRange string
|
||||
Providers string
|
||||
FSSAILicense string
|
||||
Highlights string
|
||||
Nutrients string
|
||||
SearchQuery string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (row catalogueProductRow) toModel(brand string) models.CatalogueProduct {
|
||||
return models.CatalogueProduct{
|
||||
ID: row.ID,
|
||||
Brand: brand,
|
||||
ProductName: row.ProductName,
|
||||
Title: row.Title,
|
||||
Description: row.Description,
|
||||
Category: row.Category,
|
||||
ImageID: row.ImageID,
|
||||
Images: db.GetImages(brand, row.ImageID),
|
||||
Size: row.Size,
|
||||
VariantKey: row.VariantKey,
|
||||
ProductSKU: row.ProductSKU,
|
||||
SKUSource: row.SKUSource,
|
||||
PriceRange: row.PriceRange,
|
||||
Providers: models.ParsePGArray(row.Providers),
|
||||
FSSAILicense: row.FSSAILicense,
|
||||
Highlights: models.ParsePGArray(row.Highlights),
|
||||
Nutrients: models.ParsePGArray(row.Nutrients),
|
||||
SearchQuery: row.SearchQuery,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type CatalogueRepository interface {
|
||||
GetBrands() ([]models.CatalogueBrand, error)
|
||||
GetCategories(brand string) ([]string, error)
|
||||
GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error)
|
||||
GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error)
|
||||
GetProductByID(brand string, id int64) (*models.CatalogueProduct, error)
|
||||
}
|
||||
|
||||
type catalogueRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCatalogueRepository takes the dedicated catalogue DB connection
|
||||
// (db.CatalogueDB), never the main nearledb connection.
|
||||
func NewCatalogueRepository(db *gorm.DB) CatalogueRepository {
|
||||
return &catalogueRepository{db: db}
|
||||
}
|
||||
|
||||
func tableForBrand(brand string) (string, error) {
|
||||
table, ok := catalogueBrandTables[strings.ToLower(strings.TrimSpace(brand))]
|
||||
if !ok {
|
||||
return "", ErrUnknownBrand
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func (r *catalogueRepository) GetBrands() ([]models.CatalogueBrand, error) {
|
||||
var brands []models.CatalogueBrand
|
||||
|
||||
for brand, table := range catalogueBrandTables {
|
||||
var count int64
|
||||
if err := r.db.Table(table).Count(&count).Error; err != nil {
|
||||
return nil, fmt.Errorf("counting %s: %w", table, err)
|
||||
}
|
||||
brands = append(brands, models.CatalogueBrand{Brand: brand, ProductCount: count})
|
||||
}
|
||||
|
||||
return brands, nil
|
||||
}
|
||||
|
||||
func (r *catalogueRepository) GetCategories(brand string) ([]string, error) {
|
||||
table, err := tableForBrand(brand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var categories []string
|
||||
query := fmt.Sprintf(`SELECT DISTINCT category FROM %s WHERE category IS NOT NULL ORDER BY category`, table)
|
||||
if err := r.db.Raw(query).Scan(&categories).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// GetProducts lists catalogue products. When brand is empty it browses the
|
||||
// entire catalogue (every brand merged), which is the entry point for "show
|
||||
// everything, then let the store owner choose" — brand/category/keyword are
|
||||
// optional narrowing filters on top of that, not prerequisites.
|
||||
func (r *catalogueRepository) GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
||||
if pagesize <= 0 {
|
||||
pagesize = 20
|
||||
}
|
||||
if pageno <= 0 {
|
||||
pageno = 1
|
||||
}
|
||||
|
||||
if brand != "" {
|
||||
return r.getProductsForBrand(brand, category, keyword, pageno, pagesize)
|
||||
}
|
||||
return r.getProductsAllBrands(category, keyword, pageno, pagesize)
|
||||
}
|
||||
|
||||
func (r *catalogueRepository) getProductsForBrand(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
||||
table, err := tableForBrand(brand)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (pageno - 1) * pagesize
|
||||
whereClause, args := catalogueWhereClause(category, keyword)
|
||||
|
||||
var total int64
|
||||
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s`, table, whereClause)
|
||||
if err := r.db.Raw(countQuery, args...).Scan(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var rows []catalogueProductRow
|
||||
dataQuery := fmt.Sprintf(
|
||||
`SELECT %s FROM %s WHERE %s ORDER BY id LIMIT ? OFFSET ?`,
|
||||
catalogueProductColumns, table, whereClause,
|
||||
)
|
||||
dataArgs := append(append([]interface{}{}, args...), pagesize, offset)
|
||||
if err := r.db.Raw(dataQuery, dataArgs...).Scan(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
brandKey := strings.ToLower(brand)
|
||||
products := make([]models.CatalogueProduct, len(rows))
|
||||
for i, row := range rows {
|
||||
products[i] = row.toModel(brandKey)
|
||||
}
|
||||
|
||||
return products, total, nil
|
||||
}
|
||||
|
||||
// getProductsAllBrands merges matching products across every brand table so
|
||||
// the catalogue can be browsed without picking a brand first. The whole
|
||||
// catalogue is currently a few hundred rows total, so fetching each brand's
|
||||
// filtered set and merging/sorting in Go is simple and fast; if it grows
|
||||
// much larger this should move to a single UNION ALL query instead.
|
||||
func (r *catalogueRepository) getProductsAllBrands(category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
|
||||
whereClause, args := catalogueWhereClause(category, keyword)
|
||||
|
||||
brands := make([]string, 0, len(catalogueBrandTables))
|
||||
for brand := range catalogueBrandTables {
|
||||
brands = append(brands, brand)
|
||||
}
|
||||
sort.Strings(brands)
|
||||
|
||||
var all []models.CatalogueProduct
|
||||
for _, brand := range brands {
|
||||
table := catalogueBrandTables[brand]
|
||||
|
||||
var rows []catalogueProductRow
|
||||
dataQuery := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY id`, catalogueProductColumns, table, whereClause)
|
||||
if err := r.db.Raw(dataQuery, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("querying %s: %w", table, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
all = append(all, row.toModel(brand))
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return strings.ToLower(all[i].ProductName) < strings.ToLower(all[j].ProductName)
|
||||
})
|
||||
|
||||
total := int64(len(all))
|
||||
offset := (pageno - 1) * pagesize
|
||||
if offset > len(all) {
|
||||
offset = len(all)
|
||||
}
|
||||
end := offset + pagesize
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
|
||||
return all[offset:end], total, nil
|
||||
}
|
||||
|
||||
// catalogueWhereClause builds the shared category/keyword filter used by
|
||||
// both single-brand and all-brand product queries.
|
||||
func catalogueWhereClause(category, keyword string) (string, []interface{}) {
|
||||
where := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
|
||||
if category != "" {
|
||||
where = append(where, "category = ?")
|
||||
args = append(args, category)
|
||||
}
|
||||
if keyword != "" {
|
||||
where = append(where, "(product_name ILIKE ? OR title ILIKE ? OR description ILIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
func (r *catalogueRepository) GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error) {
|
||||
table, err := tableForBrand(brand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var row catalogueProductRow
|
||||
query := fmt.Sprintf(
|
||||
`SELECT %s FROM %s WHERE product_sku = ? LIMIT 1`,
|
||||
catalogueProductColumns, table,
|
||||
)
|
||||
result := r.db.Raw(query, sku).Scan(&row)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
product := row.toModel(strings.ToLower(brand))
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
func (r *catalogueRepository) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
|
||||
table, err := tableForBrand(brand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var row catalogueProductRow
|
||||
query := fmt.Sprintf(
|
||||
`SELECT %s FROM %s WHERE id = ? LIMIT 1`,
|
||||
catalogueProductColumns, table,
|
||||
)
|
||||
result := r.db.Raw(query, id).Scan(&row)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
product := row.toModel(strings.ToLower(brand))
|
||||
return &product, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -36,6 +37,10 @@ type ProductRepository interface {
|
||||
CreateProductLocation(input []models.Productlocations) error
|
||||
CreateProductVariant(input models.Productvariant) error
|
||||
DeleteProductLocation(tenantid, locationid, productid int) error
|
||||
FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error)
|
||||
CreateProductReturningID(product models.Products) (int, error)
|
||||
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
|
||||
UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error
|
||||
}
|
||||
|
||||
type productRepository struct {
|
||||
@@ -753,3 +758,60 @@ func (r *productRepository) DeleteProductLocation(tenantid, locationid, producti
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindTenantProductByCatalogueRef looks up the tenant's existing snapshot of
|
||||
// a catalogue product, keyed on (tenantid, brand, catalogueid) since a
|
||||
// catalogue row's bare id is only unique within its own brand table.
|
||||
func (r *productRepository) FindTenantProductByCatalogueRef(tenantid int, brand string, catalogueid int64) (*models.Products, error) {
|
||||
var product models.Products
|
||||
result := r.db.Table("products").
|
||||
Where("tenantid = ? AND productbrand = ? AND catalogueid = ?", tenantid, brand, catalogueid).
|
||||
First(&product)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
// CreateProductReturningID inserts a new product snapshot and returns its
|
||||
// generated productid. Kept separate from CreateProduct so existing callers
|
||||
// of CreateProduct are unaffected.
|
||||
func (r *productRepository) CreateProductReturningID(product models.Products) (int, error) {
|
||||
if err := r.db.Create(&product).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return product.Productid, nil
|
||||
}
|
||||
|
||||
// GetImportedCatalogueRefs returns the (brand, catalogueid) pairs this
|
||||
// tenant has already imported, so a catalogue browse screen can mark items
|
||||
// as already-imported without diffing full product lists client-side. Brand
|
||||
// is optional: omitted, it covers every brand at once — needed because the
|
||||
// all-brands browse view mixes products whose bare catalogueid can collide
|
||||
// across brand tables, so brand must travel with every id.
|
||||
func (r *productRepository) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) {
|
||||
refs := make([]models.ImportedCatalogueRef, 0)
|
||||
query := r.db.Table("products").
|
||||
Select("productbrand AS brand, catalogueid").
|
||||
Where("tenantid = ? AND catalogueid IS NOT NULL AND catalogueid != 0", tenantid)
|
||||
if brand != "" {
|
||||
query = query.Where("productbrand = ?", brand)
|
||||
}
|
||||
err := query.Scan(&refs).Error
|
||||
return refs, err
|
||||
}
|
||||
|
||||
// UpdateProductPricing updates only the pricing fields on a product
|
||||
// snapshot, used when a catalogue product is re-imported with new pricing.
|
||||
func (r *productRepository) UpdateProductPricing(productid int, retailprice, productcost, taxpercent float64) error {
|
||||
return r.db.Table("products").
|
||||
Where("productid = ?", productid).
|
||||
Updates(map[string]interface{}{
|
||||
"retailprice": retailprice,
|
||||
"productcost": productcost,
|
||||
"taxpercent": taxpercent,
|
||||
}).Error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user