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,148 @@
package controllers
import (
"errors"
"nearle/repositories"
"nearle/services"
"net/http"
"strconv"
"github.com/gofiber/fiber/v2"
)
type CatalogueController struct {
catalogueService services.CatalogueService
}
func NewCatalogueController(catalogueService services.CatalogueService) *CatalogueController {
return &CatalogueController{catalogueService: catalogueService}
}
func (ctl *CatalogueController) GetBrands(c *fiber.Ctx) error {
brands, err := ctl.catalogueService.GetBrands()
if err != nil {
return c.JSON(fiber.Map{
"code": 500,
"message": "Failed to fetch catalogue brands",
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": brands,
})
}
func (ctl *CatalogueController) GetCategories(c *fiber.Ctx) error {
brand := c.Query("brand")
if brand == "" {
return c.JSON(fiber.Map{
"code": 400,
"message": "brand is required",
"status": false,
})
}
categories, err := ctl.catalogueService.GetCategories(brand)
if err != nil {
if errors.Is(err, repositories.ErrUnknownBrand) {
return c.JSON(fiber.Map{
"code": 400,
"message": "Unknown brand: " + brand,
"status": false,
})
}
return c.JSON(fiber.Map{
"code": 500,
"message": "Failed to fetch categories",
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": categories,
})
}
func (ctl *CatalogueController) GetProducts(c *fiber.Ctx) error {
// brand is an optional filter: omitting it browses the entire catalogue
// across every brand, which is the entry point for "show everything,
// then let the store owner choose".
brand := c.Query("brand")
category := c.Query("category")
keyword := c.Query("keyword")
pageno, _ := strconv.Atoi(c.Query("pageno"))
pagesize, _ := strconv.Atoi(c.Query("pagesize"))
products, total, err := ctl.catalogueService.GetProducts(brand, category, keyword, pageno, pagesize)
if err != nil {
if errors.Is(err, repositories.ErrUnknownBrand) {
return c.JSON(fiber.Map{
"code": 400,
"message": "Unknown brand: " + brand,
"status": false,
})
}
return c.JSON(fiber.Map{
"code": 500,
"message": "Failed to fetch catalogue products",
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"total": total,
"details": products,
})
}
func (ctl *CatalogueController) GetProductBySKU(c *fiber.Ctx) error {
brand := c.Query("brand")
sku := c.Query("sku")
if brand == "" || sku == "" {
return c.JSON(fiber.Map{
"code": 400,
"message": "brand and sku are required",
"status": false,
})
}
product, err := ctl.catalogueService.GetProductBySKU(brand, sku)
if err != nil {
if errors.Is(err, repositories.ErrUnknownBrand) {
return c.JSON(fiber.Map{
"code": 400,
"message": "Unknown brand: " + brand,
"status": false,
})
}
return c.JSON(fiber.Map{
"code": 500,
"message": "Failed to fetch catalogue product",
"status": false,
})
}
if product == nil {
return c.JSON(fiber.Map{
"code": 404,
"message": "Product not found",
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": product,
})
}

View File

@@ -515,6 +515,81 @@ func (ctl *ProductController) CreateProductVariant(c *fiber.Ctx) error {
})
}
func (ctl *ProductController) ImportCatalogueProduct(c *fiber.Ctx) error {
var data []models.ImportCatalogueProductRequest
if err := c.BodyParser(&data); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "Invalid request body",
"status": false,
})
}
if len(data) == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "Request body must contain at least one product",
"status": false,
})
}
for _, req := range data {
if req.Tenantid == 0 || req.Locationid == 0 || req.Brand == "" || req.Catalogueid == 0 || req.Categoryid == 0 || req.Subcategoryid == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid, locationid, brand, catalogueid, categoryid, and subcategoryid are required",
"status": false,
})
}
}
if err := ctl.productService.ImportCatalogueProduct(data); err != nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{
"status": false,
"code": http.StatusConflict,
"message": err.Error(),
})
}
return c.Status(http.StatusCreated).JSON(fiber.Map{
"status": true,
"code": http.StatusCreated,
"message": "Success",
})
}
func (ctl *ProductController) GetImportedCatalogueProducts(c *fiber.Ctx) error {
tenantID, _ := strconv.Atoi(c.Query("tenantid"))
// brand is optional: omit it to check imported status across every
// brand at once, matching the all-brands catalogue browse view.
brand := c.Query("brand")
if tenantID == 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
"code": http.StatusBadRequest,
"message": "tenantid is required",
"status": false,
})
}
refs, err := ctl.productService.GetImportedCatalogueRefs(tenantID, brand)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"code": http.StatusInternalServerError,
"message": err.Error(),
"status": false,
})
}
return c.JSON(fiber.Map{
"code": http.StatusOK,
"message": "Success",
"status": true,
"details": refs,
})
}
func (ctl *ProductController) DeleteProductLocation(c *fiber.Ctx) error {
var input struct {
Tenantid int `json:"tenantid"`