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

15
.env
View File

@@ -4,3 +4,18 @@ DB_PORT=5433
DB_NAME=nearledb
DB_USER=admin
DB_PASSWORD="Package@123#"
# --- Catalogue Postgres / pgvector (separate DB, read-only integration) ---
CATALOGUE_DB_HOST=31.97.228.132
CATALOGUE_DB_PORT=6054
CATALOGUE_DB_NAME=pgvector
CATALOGUE_DB_USER=admin
CATALOGUE_DB_PASSWORD="'Package@321#'"
# --- DigitalOcean Spaces (S3-compatible), catalogue product images ---
USE_S3=true
S3_ACCESS_KEY=DO801G8Q8JAZKF49U3WJ
S3_SECRET_KEY=lBQExYfkVqH+ybmGVmQH5MkThBbrIohA/VQLgcPUvug
S3_ENDPOINT=https://nearle.sgp1.digitaloceanspaces.com
S3_BUCKET=nearle
S3_REGION=sgp1

View File

@@ -0,0 +1,266 @@
# Store Catalogue Import — Frontend Integration Spec
Backend work is done and verified live. This doc is the handoff: build the
Admin Catalogue UI flow (browse global catalogue → choose products → import
into a specific store) against the endpoints below.
## 1. Architecture (why the API looks like this)
There are two separate Postgres databases that never talk to each other
directly:
- **CatalogueDB** (pgvector) — the global catalogue, one table per brand
(`brand_dabur`, `brand_nestle`, `brand_pepsico`, `brand_sakthi`,
`brand_manna`, `brand_naga`). ~237 products total today.
- **nearledb** — your tenant/store data (`products`, `productlocations`,
`productstocks`).
The backend bridges them using a **composite key: `(brand, catalogueid)`**.
A catalogue row's bare `id` is only unique *within its own brand table*
`brand_dabur.id=1` and `brand_nestle.id=1` are different products. Every
call that references a catalogue product must send both `brand` and
`catalogueid`, never just an id.
When a product is imported, the backend snapshots it into the tenant's own
`products` table (tagged with that brand+catalogueid) and links it to the
location via the existing stock/location system. After that, it behaves
exactly like a product the tenant created by hand — reading a store's
catalogue never touches CatalogueDB again.
## 2. Endpoints
Base path: `/live/api/v1` (replace host with your environment's API host).
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/web/catalogue/getproducts` | Browse the global catalogue. Query: `brand` (**optional** — omit to search all brands merged), `category`, `keyword`, `pageno`, `pagesize`. This is the "show everything" entry point. |
| `GET` | `/web/catalogue/getbrands` | List brands with product counts, for a brand filter chip row. |
| `GET` | `/web/products/getimportedcatalogueproducts` | Query: `tenantid` (required), `brand` (**optional** — omit to check across every brand). Returns `[{brand, catalogueid}, …]` already imported by this tenant, for badging "Imported" in the browser. |
| `GET` | `/web/products/getproductsubcategories` | Query: `tenantid`, `categoryid`. Use to populate the category/subcategory picker shown before import (see §4). |
| `POST` | `/web/products/importcatalogueproduct` | Body is an **array** — import one or many in a batch. Idempotent: re-importing the same `(tenantid, brand, catalogueid)` tops up stock and updates price instead of duplicating. |
| `GET` | `/web/products/getlocationproducts` | Query: `tenantid`, `locationid`, `pageno`, `pagesize`. The store's own catalogue view — what's actually imported. |
| `DELETE` | `/web/products/deleteproductlocation` | Body: `tenantid`, `locationid`, `productid`. Unlinks from the store; keeps the product row and order history intact (safely re-importable after). |
Mobile mirrors exist at `/mob/products/importcatalogueproduct` and
`/mob/products/getimportedcatalogueproducts` if the mobile app needs this
flow too.
## 3. Integration flow
Order matters — each step depends on data fetched in the one before it.
1. **Show everything first.** Call `catalogue/getproducts` with no `brand`.
That's the full catalogue, merged and paginated. Don't gate the list
behind a brand selector — brand/category/keyword are filters applied on
top of an already-visible list, not a prerequisite to seeing it.
2. **Mark what's already imported.** Call
`products/getimportedcatalogueproducts?tenantid=` (no `brand`, since the
list mixes brands) in parallel with step 1. Build a lookup keyed on
`` `${brand}:${catalogueid}` `` and badge matching items as "Imported".
3. **Collect what the catalogue can't supply.** The catalogue has no exact
price (only a `price_range` display string) and no mapping to this
tenant's own categories. Before enabling the Import action on a product,
require the store owner to pick `categoryid`/`subcategoryid` (from
`getproductsubcategories`) and enter `retailprice`/`productcost`/`taxpercent`.
4. **Import.** `POST products/importcatalogueproduct` with the batch. On
success, invalidate both the imported-refs query and the store-catalogue
query.
5. **Show it in the store.** Refetch `products/getlocationproducts` — the
imported item now appears like any other product, with live stock
computed from the stock ledger.
6. **Remove, if needed.** `DELETE products/deleteproductlocation`, then
invalidate the same two queries as import.
## 4. Code
TypeScript + TanStack Query (React Query), matching the existing admin app
pattern of invalidating queries after mutations.
### `api/catalogue.ts`
```ts
const API_BASE = "https://<host>/live/api/v1";
export interface CatalogueProduct {
id: number;
brand: string;
product_name: string;
category?: string;
images?: string[];
size?: string;
product_sku?: string;
price_range?: string; // display only — never an exact price
}
// brand omitted → the entire catalogue, all brands merged.
export async function getCatalogueProducts(opts: {
brand?: string; keyword?: string; pageno?: number; pagesize?: number;
} = {}) {
const { brand, keyword, pageno = 1, pagesize = 50 } = opts;
const url = new URL(`${API_BASE}/web/catalogue/getproducts`);
if (brand) url.searchParams.set("brand", brand);
if (keyword) url.searchParams.set("keyword", keyword);
url.searchParams.set("pageno", String(pageno));
url.searchParams.set("pagesize", String(pagesize));
const res = await fetch(url);
const json = await res.json();
return { products: json.details as CatalogueProduct[], total: json.total as number };
}
export interface ImportedRef { brand: string; catalogueid: number; }
// brand omitted → imported refs across every brand.
export async function getImportedCatalogueRefs(tenantid: number, brand?: string) {
const url = new URL(`${API_BASE}/web/products/getimportedcatalogueproducts`);
url.searchParams.set("tenantid", String(tenantid));
if (brand) url.searchParams.set("brand", brand);
const res = await fetch(url);
const json = await res.json();
const refs = json.details as ImportedRef[];
return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`));
}
export interface ImportCatalogueProductRequest {
tenantid: number;
locationid: number;
brand: string; // bridge key part 1
catalogueid: number; // bridge key part 2 — the catalogue row's `id`
categoryid: number; // this tenant's own category
subcategoryid: number; // this tenant's own subcategory
quantity: number;
stocktype: "in" | "out";
status: string;
retailprice: number;
productcost: number;
taxpercent: number;
}
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
const res = await fetch(`${API_BASE}/web/products/importcatalogueproduct`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(items),
});
const json = await res.json();
if (!json.status) throw new Error(json.message);
return json;
}
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {
const res = await fetch(`${API_BASE}/web/products/deleteproductlocation`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tenantid, locationid, productid }),
});
return res.json();
}
```
### `hooks/useCatalogueImport.ts`
```ts
export function useCatalogueProducts(brand?: string, keyword?: string) {
return useQuery({
queryKey: ["catalogue", "products", brand ?? "all", keyword ?? ""],
queryFn: () => getCatalogueProducts({ brand, keyword, pagesize: 100 }),
});
}
export function useImportedCatalogueRefs(tenantid: number, brand?: string) {
return useQuery({
queryKey: ["catalogue", "imported", tenantid, brand ?? "all"],
queryFn: () => getImportedCatalogueRefs(tenantid, brand),
});
}
export function useImportCatalogueProduct(tenantid: number, locationid: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (items: ImportCatalogueProductRequest[]) => importCatalogueProducts(items),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["catalogue", "imported", tenantid] });
queryClient.invalidateQueries({ queryKey: ["store-catalogue", tenantid, locationid] });
},
});
}
```
### Component usage
```tsx
// brand starts undefined: the screen opens showing the whole catalogue.
// Selecting a brand chip narrows it — it's a filter, never a gate.
function CatalogueBrowser({ tenantid, locationid }: Props) {
const [brand, setBrand] = useState<string | undefined>(undefined);
const { data } = useCatalogueProducts(brand);
const products = data?.products ?? [];
const { data: imported = new Set<string>() } = useImportedCatalogueRefs(tenantid);
const importProduct = useImportCatalogueProduct(tenantid, locationid);
function handleImport(product: CatalogueProduct, form: ImportForm) {
importProduct.mutate([{
tenantid, locationid,
brand: product.brand,
catalogueid: product.id,
categoryid: form.categoryid,
subcategoryid: form.subcategoryid,
quantity: form.quantity,
stocktype: "in",
status: "Active",
retailprice: form.retailprice,
productcost: form.productcost,
taxpercent: form.taxpercent,
}]);
}
return (
<ul>
{products.map((p) => (
<li key={`${p.brand}:${p.id}`}>
{p.product_name} ({p.brand})
{imported.has(`${p.brand}:${p.id}`)
? <span className="badge">Imported</span>
: <ImportButton onImport={(form) => handleImport(p, form)} />}
</li>
))}
</ul>
);
}
```
## 5. Gotchas
- **Always send `brand` with `catalogueid`.** Ids repeat across brands;
either one alone is ambiguous.
- **Category/subcategory must already exist for the tenant.** There's no
automatic mapping from the catalogue's free-text `category` string to
this tenant's `categoryid`/`subcategoryid` yet — the UI must require a
pick from `getproductsubcategories` before enabling Import.
- **Price is store-set, not catalogue-set.** The catalogue only has a
`price_range` display string. `retailprice`/`productcost`/`taxpercent`
always come from the store owner's input.
- **Re-importing tops up, it doesn't duplicate.** Same
`(tenantid, brand, catalogueid)` twice reuses the same product row:
quantity adds via the stock ledger, price fields overwrite with whatever
was sent that call.
- **Delete unlinks, it doesn't erase.** The product row (and any order
history referencing it) survives; the item becomes instantly
re-importable.
- **Known brands today:** `dabur`, `nestle`, `pepsico`, `sakthi`, `manna`,
`naga` — pull the live list from `getbrands` rather than hardcoding it.
## 6. Implementation checklist
- [ ] API client functions (§4) added to the frontend's API layer, with
`API_BASE` pointed at the real environment host.
- [ ] Catalogue browse screen: loads with no brand filter (all products),
brand/category/keyword as UI filters on top.
- [ ] Already-imported badge wired to `getImportedCatalogueRefs`, keyed on
`brand:catalogueid`.
- [ ] Import action collects `categoryid`, `subcategoryid`, `retailprice`,
`productcost`, `taxpercent` from the user before enabling submit.
- [ ] Import mutation invalidates both the imported-refs query and the
store-catalogue query on success.
- [ ] Store catalogue screen (`getlocationproducts`) reflects imports
immediately after the above invalidation.
- [ ] Remove action wired to `deleteproductlocation`, same invalidation.

View File

@@ -1,5 +1,5 @@
# ---------- Build Stage ----------
FROM golang:1.22 AS builder
FROM golang:1.24 AS builder
WORKDIR /app
COPY . .

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"`

View File

@@ -3,6 +3,7 @@ package db
import (
"fmt"
"log"
"net/url"
"os"
"time"
@@ -12,6 +13,10 @@ import (
var (
DB *gorm.DB
// CatalogueDB is a separate connection to the pgvector/catalogue database.
// It is intentionally isolated from DB (nearledb) so catalogue integration
// never touches the main database.
CatalogueDB *gorm.DB
)
// --------------------
@@ -36,6 +41,44 @@ func Connect() {
setupDB(DB)
fmt.Println("✅ Database connected")
connectCatalogueDB()
connectImageStore()
}
// connectCatalogueDB opens the read-only connection to the catalogue
// (pgvector) database. If its env vars are not set, catalogue endpoints
// are simply unavailable — this must never block startup of the main app.
func connectCatalogueDB() {
host := getEnv("CATALOGUE_DB_HOST", "")
if host == "" {
fmt.Println("⚠️ Catalogue DB env vars not set, skipping catalogue DB connection")
return
}
// Built as a URL (not a keyword=value DSN) and percent-encoded via
// net/url, because this credential's password contains characters
// (quotes, #) that the keyword=value DSN format would misparse as
// quoting/comment syntax.
dsnURL := url.URL{
Scheme: "postgres",
User: url.UserPassword(mustEnv("CATALOGUE_DB_USER"), mustEnv("CATALOGUE_DB_PASSWORD")),
Host: fmt.Sprintf("%s:%s", host, getEnv("CATALOGUE_DB_PORT", "5432")),
Path: "/" + mustEnv("CATALOGUE_DB_NAME"),
}
q := dsnURL.Query()
q.Set("sslmode", "disable")
dsnURL.RawQuery = q.Encode()
catalogueDB, err := gorm.Open(postgres.Open(dsnURL.String()), &gorm.Config{})
if err != nil {
log.Println("❌ Could not connect to catalogue database:", err)
return
}
setupDB(catalogueDB)
CatalogueDB = catalogueDB
fmt.Println("✅ Catalogue database connected")
}
func setupDB(database *gorm.DB) {
@@ -53,16 +96,17 @@ func setupDB(database *gorm.DB) {
// --------------------
func CloseDB() {
if DB == nil {
return
if DB != nil {
if sqlDB, err := DB.DB(); err == nil {
sqlDB.Close()
}
}
sqlDB, err := DB.DB()
if err != nil {
log.Println("Error retrieving sql.DB:", err)
return
if CatalogueDB != nil {
if sqlDB, err := CatalogueDB.DB(); err == nil {
sqlDB.Close()
}
}
fmt.Println("Connection closed Successfully")
sqlDB.Close()
}
// --------------------

155
db/imagestore.go Normal file
View File

@@ -0,0 +1,155 @@
package db
import (
"context"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// catalogueImagesPrefix is where product photos for the brand catalogue
// live in the bucket: daily/brands/{brand}/{image_id}/image_NNN.<ext>
const catalogueImagesPrefix = "daily/brands/"
// imageStore caches a public-URL listing of catalogue product images so
// GET requests never have to call out to S3 themselves.
type imageStore struct {
mu sync.RWMutex
images map[string]map[string][]string // brand -> image_id -> sorted public URLs
client *s3.Client
bucket string
publicURL string // base URL objects are served from, e.g. https://nearle.sgp1.digitaloceanspaces.com
}
var ImageStore *imageStore
// connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client
// used to resolve catalogue product images. Like the catalogue DB, this must
// never block or fail app startup — if S3 env vars are absent, image URLs
// are simply omitted from catalogue responses.
func connectImageStore() {
if getEnv("USE_S3", "") != "true" {
fmt.Println("⚠️ S3 not enabled, skipping image store")
return
}
endpoint := getEnv("S3_ENDPOINT", "")
bucket := getEnv("S3_BUCKET", "")
accessKey := getEnv("S3_ACCESS_KEY", "")
secretKey := getEnv("S3_SECRET_KEY", "")
region := getEnv("S3_REGION", "")
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
fmt.Println("⚠️ S3 env vars incomplete, skipping image store")
return
}
// S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com).
// The SDK's virtual-hosted-style client re-prepends the bucket to whatever
// host it's given, so the client must be pointed at the bare region host
// instead, or listing requests end up addressed to "nearle.nearle...".
regionHost := strings.TrimPrefix(endpoint, "https://")
regionHost = strings.TrimPrefix(regionHost, "http://")
regionHost = strings.TrimPrefix(regionHost, bucket+".")
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
log.Println("❌ Could not configure S3 client:", err)
return
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String("https://" + regionHost)
})
ImageStore = &imageStore{
images: make(map[string]map[string][]string),
client: client,
bucket: bucket,
publicURL: strings.TrimSuffix(endpoint, "/"),
}
if err := ImageStore.refresh(); err != nil {
log.Println("❌ Initial catalogue image listing failed:", err)
} else {
fmt.Println("✅ Catalogue image store loaded")
}
go ImageStore.refreshLoop()
}
func (s *imageStore) refreshLoop() {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := s.refresh(); err != nil {
log.Println("⚠️ Catalogue image refresh failed:", err)
}
}
}
// refresh lists every object under daily/brands/ and rebuilds the
// brand -> image_id -> URLs map from scratch, then swaps it in atomically.
func (s *imageStore) refresh() error {
next := make(map[string]map[string][]string)
paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(catalogueImagesPrefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.Background())
if err != nil {
return fmt.Errorf("listing %s: %w", catalogueImagesPrefix, err)
}
for _, obj := range page.Contents {
key := aws.ToString(obj.Key)
// daily/brands/{brand}/{image_id}/image_NNN.ext
parts := strings.SplitN(strings.TrimPrefix(key, catalogueImagesPrefix), "/", 3)
if len(parts) != 3 || parts[2] == "" {
continue
}
brand := strings.ToLower(parts[0])
imageID := parts[1]
if next[brand] == nil {
next[brand] = make(map[string][]string)
}
next[brand][imageID] = append(next[brand][imageID], s.publicURL+"/"+key)
}
}
for _, byImage := range next {
for _, urls := range byImage {
sort.Strings(urls)
}
}
s.mu.Lock()
s.images = next
s.mu.Unlock()
return nil
}
// GetImages returns the cached public image URLs for a brand + image_id.
// Safe to call even if the image store was never initialized.
func GetImages(brand, imageID string) []string {
if ImageStore == nil {
return nil
}
ImageStore.mu.RLock()
defer ImageStore.mu.RUnlock()
return ImageStore.images[strings.ToLower(brand)][imageID]
}

View File

@@ -18,18 +18,30 @@ type Facade struct {
PartnerController *controllers.PartnerController
CustomerController *controllers.CustomerController
StockRequestController *controllers.StockRequestController
CatalogueController *controllers.CatalogueController
}
func NewFacade(db *gorm.DB) *Facade {
// NewFacade wires up modules against the main (nearledb) connection.
// catalogueDB is a separate connection to the pgvector catalogue database;
// it may be nil if catalogue env vars are not configured, in which case
// catalogue endpoints will error at query time rather than at startup.
func NewFacade(db *gorm.DB, catalogueDB *gorm.DB) *Facade {
// User Module
userRepo := repositories.NewUserRepository(db)
userService := services.NewUserService(userRepo)
userController := controllers.NewUserController(userService)
// Catalogue Module (separate pgvector DB — never the main `db`). Built
// before the Product Module because ProductService depends on it to
// bridge catalogue imports into a tenant's own product catalogue.
catalogueRepo := repositories.NewCatalogueRepository(catalogueDB)
catalogueService := services.NewCatalogueService(catalogueRepo)
catalogueController := controllers.NewCatalogueController(catalogueService)
// Product Module
productRepo := repositories.NewProductRepository(db)
productService := services.NewProductService(productRepo)
productService := services.NewProductService(productRepo, catalogueService)
productController := controllers.NewProductController(productService)
// Order Module
@@ -77,5 +89,6 @@ func NewFacade(db *gorm.DB) *Facade {
PartnerController: partnerController,
CustomerController: customerController,
StockRequestController: stockRequestController,
CatalogueController: catalogueController,
}
}

22
go.mod
View File

@@ -1,6 +1,8 @@
module nearle
go 1.21
go 1.24
toolchain go1.24.0
require gorm.io/gorm v1.25.10
@@ -14,6 +16,24 @@ require (
cloud.google.com/go/storage v1.30.1 // indirect
firebase.google.com/go v3.13.0+incompatible // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/gofiber/fiber v1.14.6 // indirect

36
go.sum
View File

@@ -57,6 +57,42 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk=
github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1 h1:LkBKxAOE5WXjlFuFZqPG1rREnl6I6QCMElcXFDEidos=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.1/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=

View File

@@ -42,7 +42,7 @@ func main() {
// Ensure schema is updated
db.DB.AutoMigrate(&models.StockRequest{})
f := facade.NewFacade(db.DB)
f := facade.NewFacade(db.DB, db.CatalogueDB)
routes.RegisterRoutes(app, f)

86
models/catalogue.go Normal file
View File

@@ -0,0 +1,86 @@
package models
import (
"strings"
"time"
)
// CatalogueProduct maps to the per-brand tables (brand_dabur, brand_nestle, ...)
// in the separate pgvector catalogue database. The `embedding` vector column
// is intentionally not mapped here — it is never needed in an API response.
type CatalogueProduct struct {
ID int64 `json:"id"`
Brand string `json:"brand"`
ProductName string `json:"product_name"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
ImageID string `json:"image_id,omitempty"`
Images []string `json:"images,omitempty"`
Size string `json:"size,omitempty"`
VariantKey string `json:"variant_key,omitempty"`
ProductSKU string `json:"product_sku,omitempty"`
SKUSource string `json:"sku_source,omitempty"`
PriceRange string `json:"price_range,omitempty"`
Providers PGStringArray `json:"providers,omitempty"`
FSSAILicense string `json:"fssai_license,omitempty"`
Highlights PGStringArray `json:"highlights,omitempty"`
Nutrients PGStringArray `json:"nutrients,omitempty"`
SearchQuery string `json:"search_query,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// CatalogueBrand describes a brand available in the catalogue DB.
type CatalogueBrand struct {
Brand string `json:"brand"`
ProductCount int64 `json:"product_count"`
}
// PGStringArray is a plain []string used for catalogue array fields
// (providers, highlights, nutrients) in JSON responses.
type PGStringArray []string
// ParsePGArray parses a Postgres text[] literal (e.g. "{Amazon,Flipkart}")
// into a Go []string. GORM's raw-scan-into-struct silently drops slice-kind
// destination fields, so array columns are queried as `col::text` and
// converted with this function after scanning, rather than scanned directly.
func ParsePGArray(s string) PGStringArray {
s = strings.TrimSpace(s)
if s == "" || s == "{}" {
return PGStringArray{}
}
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
s = s[1 : len(s)-1]
}
var result PGStringArray
var buf strings.Builder
inQuotes := false
escaped := false
for _, r := range s {
switch {
case escaped:
buf.WriteRune(r)
escaped = false
case r == '\\':
escaped = true
case r == '"':
inQuotes = !inQuotes
case r == ',' && !inQuotes:
result = append(result, buf.String())
buf.Reset()
default:
buf.WriteRune(r)
}
}
result = append(result, buf.String())
for i, v := range result {
if v == "NULL" {
result[i] = ""
}
}
return result
}

View File

@@ -256,6 +256,33 @@ type Subcategory struct {
Image string `json:"image" gorm:"column:image"`
}
// ImportedCatalogueRef identifies a catalogue product a tenant has already
// imported. Brand is always included, even when a caller filtered by a
// single brand, because a bare catalogueid is ambiguous across brand tables.
type ImportedCatalogueRef struct {
Brand string `json:"brand"`
Catalogueid int64 `json:"catalogueid"`
}
// ImportCatalogueProductRequest is the payload for importing a product from
// the global catalogue (CatalogueDB) into a tenant's own store catalogue.
// Brand+Catalogueid is the bridge key back to CatalogueDB: a catalogue row's
// bare id is only unique within its brand table, so both are required.
type ImportCatalogueProductRequest struct {
Tenantid int `json:"tenantid"`
Locationid int `json:"locationid"`
Brand string `json:"brand"`
Catalogueid int64 `json:"catalogueid"`
Categoryid int `json:"categoryid"`
Subcategoryid int `json:"subcategoryid"`
Quantity int `json:"quantity"`
Stocktype string `json:"stocktype"`
Status string `json:"status"`
Retailprice float64 `json:"retailprice"`
Productcost float64 `json:"productcost"`
Taxpercent float64 `json:"taxpercent"`
}
type Productlocations struct {
Productlocationid int `json:"productlocationid" gorm:"Primary_Key"`
Tenantid int `json:"tenantid"`

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
}

24
routes/catalogueroutes.go Normal file
View File

@@ -0,0 +1,24 @@
package routes
import (
"nearle/facade"
"github.com/gofiber/fiber/v2"
)
func RegisterCatalogueRoutes(api fiber.Router, f *facade.Facade) {
catalogue := api.Group("/v1/web/catalogue")
catalogue.Get("/getbrands", f.CatalogueController.GetBrands)
catalogue.Get("/getcategories", f.CatalogueController.GetCategories)
catalogue.Get("/getproducts", f.CatalogueController.GetProducts)
catalogue.Get("/getproduct", f.CatalogueController.GetProductBySKU)
catalogue = api.Group("/v1/mob/catalogue")
catalogue.Get("/getbrands", f.CatalogueController.GetBrands)
catalogue.Get("/getcategories", f.CatalogueController.GetCategories)
catalogue.Get("/getproducts", f.CatalogueController.GetProducts)
catalogue.Get("/getproduct", f.CatalogueController.GetProductBySKU)
}

View File

@@ -26,6 +26,8 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
products.Get("/getallproducts", f.ProductController.GetAllProducts)
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
products.Post("/createproductlocation", f.ProductController.CreateProductLocation)
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
products.Delete("/deleteproductlocation", f.ProductController.DeleteProductLocation)
products.Post("/createproductvariant", f.ProductController.CreateProductVariant)
@@ -42,5 +44,7 @@ func RegisterProductRoutes(api fiber.Router, f *facade.Facade) {
products.Put("/update", f.ProductController.UpdateProduct)
products.Get("/getlocationproducts", f.ProductController.GetLocationProducts)
products.Put("/updateproductlocation", f.ProductController.UpdateProductLocation)
products.Post("/importcatalogueproduct", f.ProductController.ImportCatalogueProduct)
products.Get("/getimportedcatalogueproducts", f.ProductController.GetImportedCatalogueProducts)
}

View File

@@ -18,4 +18,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) {
RegisterTenantRoutes(api, f)
RegisterPartnerRoutes(api, f)
RegisterCustomerRoutes(api, f)
RegisterCatalogueRoutes(api, f)
}

View File

@@ -0,0 +1,42 @@
package services
import (
"nearle/models"
"nearle/repositories"
)
type CatalogueService 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 catalogueService struct {
repo repositories.CatalogueRepository
}
func NewCatalogueService(repo repositories.CatalogueRepository) CatalogueService {
return &catalogueService{repo: repo}
}
func (s *catalogueService) GetBrands() ([]models.CatalogueBrand, error) {
return s.repo.GetBrands()
}
func (s *catalogueService) GetCategories(brand string) ([]string, error) {
return s.repo.GetCategories(brand)
}
func (s *catalogueService) GetProducts(brand, category, keyword string, pageno, pagesize int) ([]models.CatalogueProduct, int64, error) {
return s.repo.GetProducts(brand, category, keyword, pageno, pagesize)
}
func (s *catalogueService) GetProductBySKU(brand, sku string) (*models.CatalogueProduct, error) {
return s.repo.GetProductBySKU(brand, sku)
}
func (s *catalogueService) GetProductByID(brand string, id int64) (*models.CatalogueProduct, error) {
return s.repo.GetProductByID(brand, id)
}

View File

@@ -1,6 +1,7 @@
package services
import (
"fmt"
"nearle/models"
"nearle/repositories"
"time"
@@ -28,13 +29,16 @@ type ProductService interface {
CreateProductLocation(input []models.Productlocations) error
CreateProductVariant(input models.Productvariant) error
DeleteProductLocation(tenantid, locationid, productid int) error
ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error
GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error)
}
type productService struct {
repo repositories.ProductRepository
repo repositories.ProductRepository
catalogueService CatalogueService
}
func NewProductService(repo repositories.ProductRepository) ProductService {
return &productService{repo: repo}
func NewProductService(repo repositories.ProductRepository, catalogueService CatalogueService) ProductService {
return &productService{repo: repo, catalogueService: catalogueService}
}
func (s *productService) GetProductSubCategory(categoryID, tenantID int) ([]models.ProductSubCategory, error) {
@@ -212,3 +216,75 @@ func (s *productService) DeleteProductLocation(tenantid, locationid, productid i
return s.repo.DeleteProductLocation(tenantid, locationid, productid)
}
// ImportCatalogueProduct bridges a global catalogue product (CatalogueDB) into
// a tenant's own store catalogue: it snapshots the catalogue product into the
// tenant's `products` table on first import (keyed on brand+catalogueid so
// re-imports are recognized), then links it to the location via the existing
// CreateProductLocation upsert, which already handles conflicting
// (tenantid, locationid, productid) rows and stock-ledger entries.
func (s *productService) ImportCatalogueProduct(reqs []models.ImportCatalogueProductRequest) error {
locations := make([]models.Productlocations, 0, len(reqs))
for _, req := range reqs {
catalogueProduct, err := s.catalogueService.GetProductByID(req.Brand, req.Catalogueid)
if err != nil {
return err
}
if catalogueProduct == nil {
return fmt.Errorf("catalogue product not found: brand=%s id=%d", req.Brand, req.Catalogueid)
}
existing, err := s.repo.FindTenantProductByCatalogueRef(req.Tenantid, req.Brand, req.Catalogueid)
if err != nil {
return err
}
productID := 0
if existing != nil {
productID = existing.Productid
if err := s.repo.UpdateProductPricing(productID, req.Retailprice, req.Productcost, req.Taxpercent); err != nil {
return err
}
} else {
snapshot := models.Products{
Tenantid: req.Tenantid,
Categoryid: req.Categoryid,
Subcategoryid: req.Subcategoryid,
Productname: catalogueProduct.ProductName,
Productdesc: catalogueProduct.Description,
Productsku: catalogueProduct.ProductSKU,
Productbrand: catalogueProduct.Brand,
Catalogueid: int(catalogueProduct.ID),
Productunit: catalogueProduct.Size,
Productcost: req.Productcost,
Retailprice: req.Retailprice,
Taxpercent: req.Taxpercent,
Approve: 1,
}
if len(catalogueProduct.Images) > 0 {
snapshot.Productimage = catalogueProduct.Images[0]
}
productID, err = s.repo.CreateProductReturningID(snapshot)
if err != nil {
return err
}
}
locations = append(locations, models.Productlocations{
Tenantid: req.Tenantid,
Locationid: req.Locationid,
Productid: productID,
Quantity: req.Quantity,
Stocktype: req.Stocktype,
Status: req.Status,
})
}
return s.repo.CreateProductLocation(locations)
}
func (s *productService) GetImportedCatalogueRefs(tenantid int, brand string) ([]models.ImportedCatalogueRef, error) {
return s.repo.GetImportedCatalogueRefs(tenantid, brand)
}