/** * Why the customer app shows fewer products than the console does. * * node scripts/appgap.mjs * 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 '); 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)'}`, );