From fab06bb33e55ca23890a68dd3070eb7d258e539e Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 16 Jul 2026 17:34:20 +0530 Subject: [PATCH] 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 --- .env | 15 ++ CATALOGUE_IMPORT_INTEGRATION.md | 266 +++++++++++++++++++++++++ Dockerfile | 2 +- controllers/catalogueController.go | 148 ++++++++++++++ controllers/productController.go | 75 +++++++ db/connect.go | 58 +++++- db/imagestore.go | 155 ++++++++++++++ facade/container.go | 17 +- go.mod | 22 +- go.sum | 36 ++++ main.go | 2 +- models/catalogue.go | 86 ++++++++ models/product.go | 27 +++ repositories/catalogueRepository.go | 299 ++++++++++++++++++++++++++++ repositories/productRepository.go | 62 ++++++ routes/catalogueroutes.go | 24 +++ routes/productroutes.go | 4 + routes/routes.go | 1 + services/catalogueService.go | 42 ++++ services/productService.go | 82 +++++++- 20 files changed, 1408 insertions(+), 15 deletions(-) create mode 100644 CATALOGUE_IMPORT_INTEGRATION.md create mode 100644 controllers/catalogueController.go create mode 100644 db/imagestore.go create mode 100644 models/catalogue.go create mode 100644 repositories/catalogueRepository.go create mode 100644 routes/catalogueroutes.go create mode 100644 services/catalogueService.go diff --git a/.env b/.env index e45fc82..8a9f2fa 100644 --- a/.env +++ b/.env @@ -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 diff --git a/CATALOGUE_IMPORT_INTEGRATION.md b/CATALOGUE_IMPORT_INTEGRATION.md new file mode 100644 index 0000000..c8f42ca --- /dev/null +++ b/CATALOGUE_IMPORT_INTEGRATION.md @@ -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:///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(undefined); + const { data } = useCatalogueProducts(brand); + const products = data?.products ?? []; + const { data: imported = new Set() } = 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 ( +
    + {products.map((p) => ( +
  • + {p.product_name} ({p.brand}) + {imported.has(`${p.brand}:${p.id}`) + ? Imported + : handleImport(p, form)} />} +
  • + ))} +
+ ); +} +``` + +## 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. diff --git a/Dockerfile b/Dockerfile index fbe2be6..51abd03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ---------- Build Stage ---------- -FROM golang:1.22 AS builder +FROM golang:1.24 AS builder WORKDIR /app COPY . . diff --git a/controllers/catalogueController.go b/controllers/catalogueController.go new file mode 100644 index 0000000..d550109 --- /dev/null +++ b/controllers/catalogueController.go @@ -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, + }) +} diff --git a/controllers/productController.go b/controllers/productController.go index 7f026fb..1e3d014 100644 --- a/controllers/productController.go +++ b/controllers/productController.go @@ -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"` diff --git a/db/connect.go b/db/connect.go index 846a2c8..8fc5169 100644 --- a/db/connect.go +++ b/db/connect.go @@ -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() } // -------------------- diff --git a/db/imagestore.go b/db/imagestore.go new file mode 100644 index 0000000..f453ade --- /dev/null +++ b/db/imagestore.go @@ -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. +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] +} diff --git a/facade/container.go b/facade/container.go index 77b3961..3a88e31 100644 --- a/facade/container.go +++ b/facade/container.go @@ -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, } } diff --git a/go.mod b/go.mod index 1012eac..9797ad6 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a41aaf4..71179bd 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index bd2b607..36ebb48 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/models/catalogue.go b/models/catalogue.go new file mode 100644 index 0000000..cef55c9 --- /dev/null +++ b/models/catalogue.go @@ -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 +} diff --git a/models/product.go b/models/product.go index 15d5d16..e34b22c 100644 --- a/models/product.go +++ b/models/product.go @@ -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"` diff --git a/repositories/catalogueRepository.go b/repositories/catalogueRepository.go new file mode 100644 index 0000000..7063dd4 --- /dev/null +++ b/repositories/catalogueRepository.go @@ -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 +} diff --git a/repositories/productRepository.go b/repositories/productRepository.go index e04aaf4..d15a663 100644 --- a/repositories/productRepository.go +++ b/repositories/productRepository.go @@ -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 +} diff --git a/routes/catalogueroutes.go b/routes/catalogueroutes.go new file mode 100644 index 0000000..c35c4b5 --- /dev/null +++ b/routes/catalogueroutes.go @@ -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) +} diff --git a/routes/productroutes.go b/routes/productroutes.go index d132359..07585c3 100644 --- a/routes/productroutes.go +++ b/routes/productroutes.go @@ -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) } diff --git a/routes/routes.go b/routes/routes.go index 04ab3a5..b6b9f91 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -18,4 +18,5 @@ func RegisterRoutes(app *fiber.App, f *facade.Facade) { RegisterTenantRoutes(api, f) RegisterPartnerRoutes(api, f) RegisterCustomerRoutes(api, f) + RegisterCatalogueRoutes(api, f) } diff --git a/services/catalogueService.go b/services/catalogueService.go new file mode 100644 index 0000000..30365b0 --- /dev/null +++ b/services/catalogueService.go @@ -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) +} diff --git a/services/productService.go b/services/productService.go index 7ff3457..800e47f 100644 --- a/services/productService.go +++ b/services/productService.go @@ -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) +} +