247 lines
9.6 KiB
JavaScript
247 lines
9.6 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
}
|