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:
266
CATALOGUE_IMPORT_INTEGRATION.md
Normal file
266
CATALOGUE_IMPORT_INTEGRATION.md
Normal 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.
|
||||
Reference in New Issue
Block a user