Files
nearle_daily_admin_web/scripts/appgap.mjs
2026-09-28 17:24:00 +05:30

194 lines
7.5 KiB
JavaScript

/**
* Why the customer app shows fewer products than the console does.
*
* node scripts/appgap.mjs <tenantid> <locationid>
* npm run appgap 1135 1166
*
* Straight at Fiesta — no Hasura, no admin secret, nothing to configure. The
* first version of this went at the database through Hasura, which was the
* wrong instrument twice over: it needed a secret, it 404'd on an admin API
* that is often disabled, and it answered a question about the DATABASE when
* the question is about what the API returns. This calls the very endpoint the
* app calls and compares it with what the console can see.
*
* ── The three filters ────────────────────────────────────────────────────────
*
* `getproductsbysubcategory` drops a product for one of three reasons, and none
* of them is an error, logged or visible from either end:
*
* A. `WHERE a.categoryid = 2` — not optional (`productRepository.go:865`).
* A product in another category cannot appear, whatever else is true.
*
* B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. No row
* for this outlet means the join yields NULL and the WHERE drops it. Being
* in the catalogue is not the same as being on a shelf.
*
* C. The grouping in `GetProductsBySubcategory` collects products under each
* real subcategory of category 2, then sweeps up `subcategoryid = 0` as
* "Uncategorized". A subcategoryid that is non-zero and NOT a subcategory
* of 2 matches neither and vanishes — present in the SQL, absent from the
* JSON.
*/
/**
* Through the console's own host, not Fiesta directly.
*
* `app.nearledaily.com/fiesta/...` is the nginx proxy the deployed console
* already uses, so this script exercises exactly the path the browser takes —
* if the proxy is misconfigured this finds out, where hitting fiesta.nearle.app
* would quietly work and prove nothing about production.
*
* Override with FIESTA_URL to point at the backend directly or at a dev server.
*/
const BASE = process.env.FIESTA_URL ?? 'https://app.nearledaily.com/fiesta';
const WEB = `${BASE}/live/api/v1/web`;
const MOB = `${BASE}/live/api/v1/mob`;
const [, , tenantArg, locationArg] = process.argv;
const tenantid = Number(tenantArg);
const locationid = Number(locationArg);
if (!tenantid || !locationid) {
console.error('Usage: node scripts/appgap.mjs <tenantid> <locationid>');
process.exit(1);
}
/** Fiesta answers under `details` in most places and `data` in a few. */
async function get(url, params) {
const query = new URLSearchParams(
Object.entries(params).filter(([, value]) => value !== undefined && value !== ''),
);
let response;
try {
response = await fetch(`${url}?${query}`, { headers: { Accept: 'application/json' } });
} catch (cause) {
console.error(`Could not reach ${BASE} — ${cause.message}`);
process.exit(1);
}
const payload = await response.json().catch(() => null);
if (!payload) {
console.error(`Malformed response from ${url} (HTTP ${response.status})`);
process.exit(1);
}
return payload.details ?? payload.data ?? null;
}
/**
* Every product the tenant owns.
*
* `getallproducts` answers `[]models.Tenantproducts` — `{tenant, products}`
* groups, not a flat list — and under `data` rather than `details`.
*/
async function allProducts() {
const groups = await get(`${WEB}/products/getallproducts`, { tenantid });
if (!Array.isArray(groups)) return [];
return groups.flatMap((group) => group?.products ?? []);
}
/**
* What is actually listed at this outlet.
*
* Paged, and the page size matters: the default is 50, so a shop with 200
* products would look like one with 50 and every product past the first page
* would be miscounted as "not listed". Walked until a short page comes back.
*/
async function locationProducts() {
const rows = [];
const pagesize = 200;
for (let pageno = 1; pageno <= 50; pageno += 1) {
const page = await get(`${WEB}/products/getlocationproducts`, {
tenantid,
locationid,
pageno,
pagesize,
});
const batch = Array.isArray(page) ? page : [];
rows.push(...batch);
if (batch.length < pagesize) break;
}
return rows;
}
/** Exactly what the app asks for, so the comparison is against reality. */
async function appView() {
const payload = await get(`${MOB}/products/getproductsbysubcategory`, {
categoryid: 2,
tenantid,
locationid,
});
const details = payload?.details ?? (Array.isArray(payload) ? payload : []);
return Array.isArray(details) ? details : [];
}
const [products, listedRows, groups, subcategories] = await Promise.all([
allProducts(),
locationProducts(),
appView(),
get(`${WEB}/products/getproductsubcategories`, { tenantid, categoryid: 2 }),
]);
if (products.length === 0) {
console.log(`Tenant ${tenantid} has no products at all — nothing for the app to show.`);
process.exit(0);
}
const listed = new Set(listedRows.map((row) => row.productid));
/**
* `subcatid`/`subcatname`, not `subcategoryid`/`subcategoryname`.
*
* This read the long names — the ones `getproductsubcategories` does NOT send —
* so every entry was `undefined → undefined`, the map collapsed to a single
* junk key, and check C below could never match. The footer duly announced
* "(none returned)" for tenant 1147, whose category 2 has six subcategories.
* A diagnostic that is confidently wrong is worse than one that is missing.
*/
const realSubs = new Map(
(Array.isArray(subcategories) ? subcategories : []).map((row) => [row.subcatid, row.subcatname]),
);
const inApp = new Set();
for (const group of groups) {
for (const product of group.products ?? []) inApp.add(product.productid);
}
const buckets = new Map();
const examples = new Map();
for (const product of products) {
let reason;
if (inApp.has(product.productid)) {
reason = 'OK — the app shows this';
} else if (product.categoryid !== 2) {
reason = `A — categoryid is ${product.categoryid}, the app only asks for 2`;
} else if (!listed.has(product.productid)) {
reason = 'B — not listed at this outlet (no productlocations row)';
} else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) {
reason = `C — subcategoryid ${product.subcategoryid} is not a subcategory of 2, so it is dropped`;
} else {
// Everything checks out and it still is not there. Worth its own bucket
// rather than being folded into one of the above: a wrong guess here would
// send someone fixing data that is already correct.
reason = '? — passes all three checks but the app still does not return it';
}
const key = reason.replace(/\d+/g, 'N');
buckets.set(key, (buckets.get(key) ?? 0) + 1);
if (!examples.has(key)) examples.set(key, { product, reason });
}
console.log(`Tenant ${tenantid}, outlet ${locationid}`);
console.log(` ${products.length} products in the catalogue`);
console.log(` ${listed.size} listed at this outlet`);
console.log(` ${inApp.size} returned by the app's endpoint\n`);
for (const [key, count] of [...buckets.entries()].sort((a, b) => b[1] - a[1])) {
const { product, reason } = examples.get(key);
console.log(` ${String(count).padStart(5)} ${reason}`);
console.log(
` e.g. "${product.productname}" — id ${product.productid}, category ${product.categoryid}, subcategory ${product.subcategoryid}`,
);
}
console.log(
`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none returned)'}`,
);