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:
Suriya
2026-07-16 17:34:20 +05:30
parent 950064de6c
commit fab06bb33e
20 changed files with 1408 additions and 15 deletions

View 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
}

View File

@@ -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
}