Bridge global catalogue DB to per-store product catalogue

Adds a separate CatalogueDB (pgvector) connection alongside the main
nearledb, plus a new catalogue module (repository/service/controller/
routes) to browse it by brand, category, and keyword, with brand
optional so the whole ~237-product catalogue can be browsed unfiltered.

Adds the actual bridge: importing a catalogue product snapshots it into
the tenant's own products table (keyed on brand+catalogueid, since a
catalogue row's bare id is only unique within its own brand table),
then links it via the existing productlocations upsert. Re-importing
tops up stock and refreshes price instead of duplicating. Also adds an
imported-refs endpoint so the frontend can badge already-imported items
without diffing full product lists, and wires the new AWS S3 image
store used to resolve catalogue product photos.

Bumps Go/Docker to 1.24 for the AWS SDK dependency this needs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-07-16 17:34:20 +05:30
parent 950064de6c
commit fab06bb33e
20 changed files with 1408 additions and 15 deletions

View File

@@ -0,0 +1,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.