/** * Every product on the platform the customer app cannot show, and why. * * node scripts/appsweep.mjs * node scripts/appsweep.mjs --json > sweep.json * * `appgap.mjs` answers this for ONE outlet and is the tool to reach for when * somebody reports a specific shop. This is the platform-wide version: it walks * every tenant, in every approval state, and reports the products that are * unreachable no matter what the app asks for. * * ── The rule it applies ────────────────────────────────────────────────────── * * Read off the backend rather than inferred from responses * (`controllers/productController.go:431`, `repositories/productRepository.go:858`): * * 1. `GetProductsBySubcategory` REJECTS `categoryid = 0` with a 400 — * "Valid categoryid is required". It is the first thing the controller * does. * 2. The query then filters `WHERE a.categoryid = ?` unconditionally. * * Together those mean a product stored with `categoryid = 0` is returned for NO * request the app can make. Not "usually hidden" — unreachable. It is still * listed by `getlocationproducts`, which does not filter on category, so the * console shows it and the shop believes it is on sale. * * Nothing else in that query gates visibility: there is no filter on * `approved`, `publishedat`, `productstatus`, or stock level. A product with * zero stock still appears. So `categoryid = 0` is the whole defect, and this * script looks for exactly it. */ const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app'; const WEB = `${BASE}/live/api/v1/web`; const asJson = process.argv.includes('--json'); /** Fiesta answers under `details` in most places and `data` in a few. */ 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; } /** Requests that never succeeded. A non-empty list invalidates the report. */ const failures = []; /** * Bounded concurrency, with retries, and failures that are never swallowed. * * The first version of this caught every error and substituted `null`. Run * across 262 tenants at a concurrency of 8, enough requests were refused that * it reported ONE affected tenant out of three known ones — and reported it as * a clean result, with no indication anything had gone wrong. A sweep that * fails silently is worse than no sweep: it is used to close the investigation. * * So: three attempts with a widening delay, and anything still failing is * recorded and printed at the end as an explicit gap in coverage. */ async function mapLimit(items, limit, fn, label = 'request') { const out = new Array(items.length); let next = 0; await Promise.all( Array.from({ length: Math.min(limit, items.length) }, async () => { for (;;) { const i = next++; if (i >= items.length) return; let lastError; for (let attempt = 0; attempt < 3; attempt++) { try { out[i] = await fn(items[i], i); lastError = null; break; } catch (cause) { lastError = cause; await new Promise((r) => setTimeout(r, 250 * (attempt + 1))); } } if (lastError) { out[i] = null; failures.push(`${label}[${i}]: ${lastError?.message ?? lastError}`); } } }), ); return out; } /** * Every tenant, from two sources because neither is complete on its own. * * `getalltenants` paginates and its `pageno` is 1-BASED — page 0 returns an * empty list rather than the first page, the same off-by-one that * `getlocationproducts` has. It carries 262 tenants where the status lists * carry 142. * * `/tenants/search` is still needed alongside it: it branches on the word * "pending" and queries `approved = 0` instead of a status * (`tenantRepository.go:45-77`), which is the only way to learn that a tenant * is unapproved. Suriya Store is one. * * Building from the status lists ALONE was the first version's other bug: R * mart (1147) appears in none of the three, and a sweep that cannot see a * tenant reports it as having nothing wrong. */ async function allTenants() { 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.has(t.tenantid)) seen.set(t.tenantid, { ...t }); if (list.length < 200) break; } for (const status of ['Active', 'pending', 'InActive']) { const rows = (await get('/tenants/search', { status })) ?? []; for (const t of Array.isArray(rows) ? rows : []) { if (!t?.tenantid) continue; const existing = seen.get(t.tenantid) ?? { ...t }; seen.set(t.tenantid, { ...existing, approvalState: status }); } } return [...seen.values()]; } const tenants = await allTenants(); if (!asJson) console.error(`Scanning ${tenants.length} tenants…`); /* Pass one — the whole platform, one request per tenant. `getallproducts` returns {tenant, products} groups, not a flat list. */ const scanned = await mapLimit(tenants, 8, async (tenant) => { const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? []; const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []); return { tenant, products, orphans: products.filter((p) => !p.categoryid) }; }, "tenant-products"); const affected = scanned.filter((row) => row && row.orphans.length > 0); /* Pass two — only the tenants that failed, so the outlet detail costs nothing on a clean platform. */ const detailed = await mapLimit(affected, 6, async (row) => { const locations = (await get('/tenants/gettenantlocations', { tenantid: row.tenant.tenantid })) ?? []; const branches = await mapLimit(Array.isArray(locations) ? locations : [], 4, async (loc) => { const shelved = (await get('/products/getlocationproducts', { tenantid: row.tenant.tenantid, locationid: loc.locationid, pageno: 1, pagesize: 500, })) ?? []; const list = Array.isArray(shelved) ? shelved : []; const hidden = list.filter((p) => !p.categoryid); return { locationid: loc.locationid, locationname: loc.locationname, shelved: list.length, hidden: hidden.length, // The number that matters to a shopper: an outlet whose entire range is // invisible looks like a closed shop, not like a partial catalogue. visible: list.length - hidden.length, }; }); return { ...row, branches: branches.filter(Boolean) }; }, "tenant-branches"); /* Coverage is part of the result, not a footnote. A tenant whose products never loaded is UNKNOWN, not clean, and the difference decides whether this report can be used to say the platform is fixed. */ const unreached = scanned.filter((row) => !row).length; if (asJson) { console.log( JSON.stringify( { scannedTenants: tenants.length, affectedTenants: detailed.length, orphanProducts: detailed.reduce((n, r) => n + r.orphans.length, 0), tenants: detailed.map((r) => ({ tenantid: r.tenant.tenantid, tenantname: r.tenant.tenantname, approvalState: r.tenant.approvalState, totalProducts: r.products.length, orphans: r.orphans.map((p) => ({ productid: p.productid, productname: p.productname, productbrand: p.productbrand, catalogueid: p.catalogueid, retailprice: p.retailprice, })), branches: r.branches, })), }, null, 2, ), ); } else { const orphanCount = detailed.reduce((n, r) => n + r.orphans.length, 0); const blindOutlets = detailed.flatMap((r) => r.branches.filter((b) => b.shelved > 0 && b.visible === 0), ); console.log(`\n${tenants.length} tenants scanned`); console.log(`${detailed.length} affected`); console.log(`${orphanCount} products with categoryid 0 — invisible in the app`); console.log(`${blindOutlets.length} outlets stocked but showing NOTHING to shoppers\n`); if (unreached > 0 || failures.length > 0) { console.log( `!! ${unreached} tenants could not be read after 3 attempts — this report is INCOMPLETE `, ); for (const f of failures.slice(0, 10)) console.log(` ${f}`); if (failures.length > 10) console.log(` … ${failures.length - 10} more `); console.log(); } for (const row of detailed.sort((a, b) => b.orphans.length - a.orphans.length)) { const { tenant } = row; console.log( `${tenant.tenantid} ${tenant.tenantname}` + ` — ${row.orphans.length}/${row.products.length} products hidden` + (tenant.approvalState === 'pending' ? ' [unapproved]' : ''), ); for (const p of row.orphans) { console.log(` ${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`); } for (const b of row.branches) { if (b.shelved === 0) continue; const flag = b.visible === 0 ? ' ← app shows an EMPTY shop' : ''; console.log( ` · ${String(b.locationname ?? b.locationid).padEnd(28)}` + ` ${b.visible}/${b.shelved} visible${flag}`, ); } console.log(); } }