/** * Repairs the products the customer app cannot show. * * node scripts/appfix.mjs # dry run — prints, writes nothing * node scripts/appfix.mjs --apply # performs the repair * node scripts/appfix.mjs --apply --tenant 1135 * * `appsweep.mjs` finds them; this puts them right. Same detection, so the two * cannot disagree about what is broken. * * ── What it does, and why it is shaped like this ───────────────────────────── * * The repair is "give the product a category", and for a long time there was no * way to do it. `products/update` writes only `productlocations.status` despite * its name, and `importcatalogueproduct` took an existing product down a branch * that corrected the PRICE and left the category alone — so re-importing, the * obvious fix, appeared to work and changed nothing. * * That branch now also calls `UpdateProductCategory` * (`services/productService.go`), which makes re-import the repair path. This * script drives it: for each orphan it re-sends the original import with a real * `categoryid`. * * REQUIRES THE FIXED BACKEND. Against the currently deployed one every call * returns 200 and nothing changes, which is exactly the failure that makes this * bug expensive — so the script verifies each product afterwards and reports * what actually moved rather than what it asked for. * * ── What it deliberately does not do ───────────────────────────────────────── * * It sends `quantity: 0` and `stocktype: "in"`, so no stock ledger entry is * written — `CreateProductLocation` only records stock when quantity > 0. The * products already have their stock and this must not add to it. * * It re-sends each product's EXISTING price, cost and tax, because the same * call updates pricing. Sending zeros would wipe the prices while fixing the * category. */ const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app'; const WEB = `${BASE}/live/api/v1/web`; const apply = process.argv.includes('--apply'); const tenantArg = process.argv.indexOf('--tenant'); const onlyTenant = tenantArg > -1 ? Number(process.argv[tenantArg + 1]) : null; async function get(path, params = {}) { const query = new URLSearchParams( Object.entries(params).filter(([, v]) => v !== undefined && v !== ''), ); const response = await fetch(`${WEB}${path}?${query}`, { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`); const payload = await response.json().catch(() => null); return payload?.details ?? payload?.data ?? null; } async function post(path, body) { const response = await fetch(`${WEB}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), }); const payload = await response.json().catch(() => null); return { ok: response.ok && payload?.status !== false, status: response.status, payload }; } async function tenantsWithOrphans() { const seen = new Map(); for (let page = 1; page <= 20; page++) { const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? []; const list = Array.isArray(rows) ? rows : []; for (const t of list) if (t?.tenantid) seen.set(t.tenantid, t); if (list.length < 200) break; } const out = []; for (const tenant of seen.values()) { if (onlyTenant && tenant.tenantid !== onlyTenant) continue; const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []); const orphans = products.filter((p) => !p.categoryid); if (orphans.length === 0) continue; /** * Where each orphan already sits. * * `getallproducts` is tenant-wide and carries NO locationid — the first * version of this read `p.locationid` off it, got undefined, sent 0, and * every repair came back "missing required field(s): locationid". Nothing * was written, which is the one good thing about that failure. * * The outlet matters beyond passing validation. Import writes a * productlocations row, so naming an outlet the product is NOT on would put * it on that shelf — silently extending the product's reach as a side * effect of a repair. Only an outlet where it is already stocked is safe: * there the upsert lands on the existing row. */ const locations = (await get('/tenants/gettenantlocations', { tenantid: tenant.tenantid })) ?? []; const placement = new Map(); for (const loc of Array.isArray(locations) ? locations : []) { const rows = (await get('/products/getlocationproducts', { tenantid: tenant.tenantid, locationid: loc.locationid, pageno: 1, pagesize: 500, })) ?? []; for (const row of Array.isArray(rows) ? rows : []) { if (!placement.has(row.productid)) placement.set(row.productid, { loc, row }); } } out.push({ tenant, orphans, placement }); } return out; } /** * The category to file a product under. * * `gettenantcategories` is synthesised from the categories the tenant's own * products already use, so it is the tenant's real answer rather than the * master table's — which is unscoped and, for the tenants seen here, offered a * category (1001) that the app does not browse. * * Refusing rather than guessing when the tenant has none: a wrong category is * findable and fixable, but writing one at random across a live catalogue is * not something a repair script should decide. */ async function categoryFor(tenantid) { const rows = (await get('/products/gettenantcategories', { tenantid })) ?? []; const usable = (Array.isArray(rows) ? rows : []).filter((r) => r?.categoryid > 0); return usable[0]?.categoryid ?? null; } const work = await tenantsWithOrphans(); if (work.length === 0) { console.log('Nothing to repair — no product is missing a category.'); process.exit(0); } console.log(apply ? 'APPLYING repairs\n' : 'DRY RUN — nothing will be written. Pass --apply.\n'); let repaired = 0; let unchanged = 0; let refused = 0; for (const { tenant, orphans, placement } of work) { const categoryid = await categoryFor(tenant.tenantid); console.log(`${tenant.tenantid} ${tenant.tenantname} — ${orphans.length} to repair`); if (!categoryid) { console.log(' SKIPPED: this tenant has no category of its own to file into.\n'); refused += orphans.length; continue; } for (const p of orphans) { const label = `${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`; if (!p.productbrand || !p.catalogueid) { // Not imported from the global catalogue, so brand+catalogueid cannot // address it and re-import is not available. Says so rather than // reporting a success it did not achieve. console.log(` ${label} — SKIPPED: no catalogue reference to re-import from`); refused++; continue; } const at = placement.get(p.productid); if (!at) { console.log(` ${label} — SKIPPED: not stocked at any outlet, so there is no safe row to repair through`); refused++; continue; } /** * A product with no price must not be made visible. * * Repairing the category is what puts a product in front of shoppers, and * the app has no price floor — `GetProducts` filters on category and outlet * and nothing else. Fixing a product priced at 0 would not "restore" it; it * would publish a free one. Idhayam Sesame Oil 500ml (7083) is in exactly * this state, priced nowhere, and it wants a price before it wants a * category. */ const price = Number(p.retailprice ?? 0); if (!(price > 0)) { console.log(` ${label} — SKIPPED: no price set. Repairing this would list it at ₹0. Price it first.`); refused++; continue; } if (!apply) { console.log( ` ${label} → categoryid ${categoryid} (via ${at.loc.locationname ?? at.loc.locationid}, price ₹${price} unchanged)`, ); continue; } const result = await post('/products/importcatalogueproduct', [ { tenantid: tenant.tenantid, // An outlet the product ALREADY sits at, so the upsert lands on the // existing row instead of putting it on a new shelf. The category // itself is written to `products`, which is tenant-wide, so one call // fixes the product everywhere. locationid: at.loc.locationid, brand: p.productbrand, catalogueid: p.catalogueid, categoryid, subcategoryid: p.subcategoryid ?? 0, // Zero, so CreateProductLocation writes no stock ledger entry — it only // records stock when quantity > 0. These products already have their // stock and a repair must not add to it. quantity: 0, stocktype: 'in', status: at.row.productstatus || p.productstatus || 'Active', // The product's OWN current values, re-sent unchanged. The same call // updates pricing, and the location upsert sets productlocations.price // from retailprice — verified equal for every product being repaired // here, so this round-trips rather than overwriting an outlet price. retailprice: price, productcost: p.productcost ?? 0, taxpercent: p.taxpercent ?? 0, }, ]); if (!result.ok) { console.log(` ${label} — FAILED: HTTP ${result.status} ${result.payload?.message ?? ''}`); refused++; continue; } // Verified, not assumed. The whole reason this bug survived is that the // call that was supposed to fix it returned success and did nothing. const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; const after = (Array.isArray(groups) ? groups : []) .flatMap((g) => g?.products ?? []) .find((x) => x.productid === p.productid); if (after?.categoryid > 0) { console.log(` ${label} → categoryid ${after.categoryid} ✓`); repaired++; } else { console.log( ` ${label} — NO CHANGE: the call succeeded but the category is still 0.` + ' The backend fix is not deployed.', ); unchanged++; } } console.log(); } console.log(`repaired ${repaired} · unchanged ${unchanged} · skipped ${refused}`); if (unchanged > 0) { console.log( '\nProducts reported NO CHANGE need the backend fix deployed' + ' (services/productService.go — re-import must call UpdateProductCategory).', ); process.exit(1); }