From 904a5d816fd907431459c0977465bd84d692bfbe Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 27 Aug 2026 17:08:21 +0530 Subject: [PATCH] env edited --- .env | 13 +++ package.json | 1 + scripts/appgap.mjs | 187 ++++++++++++++++++++++++++++++++++++++++++++ scripts/db.mjs | 150 ++++++++++++++++++++++++++++++++++- src/api/client.ts | 20 ++++- src/api/products.ts | 18 ++++- 6 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 .env create mode 100644 scripts/appgap.mjs diff --git a/.env b/.env new file mode 100644 index 0000000..6848860 --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +# The Fiesta API host. Read by Vite at build time and compiled into the bundle. +# +# This is the single source of truth for where the console talks to the backend, +# and it is not a secret — it is the same public host the customer app calls. +# +# Committed on purpose: a deployed build has to carry it, and a value that lives +# only on one developer's machine is one the build server does not have. Vite +# only exposes `VITE_`-prefixed names to client code, so nothing else in here +# would reach the browser. +# +# `.env.local` overrides this and is gitignored — that is the file to use for a +# staging backend, or `/fiesta` to route through the dev proxy instead. +VITE_API_BASE="https://fiesta.nearle.app" diff --git a/package.json b/package.json index d410f9a..776c290 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "tsx --test \"src/**/*.test.ts\"", "contract": "node scripts/contract.mjs", "db": "node scripts/db.mjs", + "appgap": "node scripts/appgap.mjs", "verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"" }, "dependencies": { diff --git a/scripts/appgap.mjs b/scripts/appgap.mjs new file mode 100644 index 0000000..7440939 --- /dev/null +++ b/scripts/appgap.mjs @@ -0,0 +1,187 @@ +/** + * 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)); +const realSubs = new Map( + (Array.isArray(subcategories) ? subcategories : []).map((row) => [ + row.subcategoryid, + row.subcategoryname, + ]), +); + +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)'}`, +); diff --git a/scripts/db.mjs b/scripts/db.mjs index 756d878..061a47e 100644 --- a/scripts/db.mjs +++ b/scripts/db.mjs @@ -32,7 +32,53 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); -const ENDPOINT = process.env.HASURA_URL ?? 'https://api.workolik.com/v1/graphql'; +/** + * Where Hasura actually lives, discovered rather than assumed. + * + * The first version of this hardcoded `/v1/graphql` at the host root and got a + * 404. The old console's proxy is the clue it should have read: it rewrites + * `/hasura` to `/api/rest/`, which means Hasura is mounted under `/api`, not at + * the root. Rather than swap one guess for another, this tries the candidates + * and uses whichever answers. + * + * Override with HASURA_URL if it moves again — pass the full GraphQL URL. + */ +const ENDPOINT_CANDIDATES = process.env.HASURA_URL + ? [process.env.HASURA_URL] + : [ + 'https://api.workolik.com/api/v1/graphql', + 'https://api.workolik.com/v1/graphql', + 'https://api.workolik.com/hasura/v1/graphql', + ]; + +let ENDPOINT = ENDPOINT_CANDIDATES[0]; + +/** Finds the first candidate that answers a trivial query. */ +async function resolveEndpoint() { + for (const candidate of ENDPOINT_CANDIDATES) { + try { + const response = await fetch(candidate, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET }, + body: JSON.stringify({ query: '{ __typename }' }), + }); + if (!response.ok) continue; + const payload = await response.json().catch(() => null); + if (payload && !payload.errors) { + ENDPOINT = candidate; + return candidate; + } + } catch { + // Next candidate. + } + } + console.error( + 'Could not find the Hasura GraphQL endpoint. Tried:\n' + + ENDPOINT_CANDIDATES.map((c) => ` ${c}`).join('\n') + + '\nSet HASURA_URL to the full GraphQL URL and run again.', + ); + process.exit(1); +} /** Where the old console keeps its gitignored secret, relative to this repo. */ const ENV_CANDIDATES = [ @@ -223,6 +269,103 @@ async function sql(statement) { console.log(`\n${Math.max(0, rows.length - 1)} rows`); } +/** + * Why the app shows fewer products than the console does. + * + * node scripts/db.mjs appgap + * + * `getproductsbysubcategory` is what the customer app browses with, and three + * separate conditions decide whether a product survives it. None of them is an + * error and none of them is logged — a product that fails any one simply is not + * in the response, which is why the console can be full and the app empty. + * + * A. `WHERE a.categoryid = ?` — the caller passes 2, and the filter is not + * optional (`productRepository.go:865`). A product in any other category is + * invisible to this endpoint no matter what else is true of it. + * + * B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. A product + * with no row for THIS outlet joins to NULL, and the WHERE then drops it. + * Being in the catalogue is not the same as being on a shelf: something has + * to write `productlocations`, and nothing does that automatically. + * + * C. The grouping in `GetProductsBySubcategory` walks the real subcategories + * of category 2 and collects products matching each, then sweeps up + * everything with `subcategoryid = 0` as "Uncategorized". A product whose + * subcategoryid is non-zero but is NOT a subcategory of category 2 matches + * neither loop and vanishes — it is in the query results and absent from + * the response. This one is worth looking for first, because it looks like + * nothing at all. + */ +async function appgap(tenantid, locationid) { + const tid = Number(tenantid); + const lid = Number(locationid); + if (!tid || !lid) { + console.error('Usage: node scripts/db.mjs appgap '); + process.exit(1); + } + + // GraphQL, not `run_sql`. + // + // `run_sql` lives on Hasura's `/v2/query` admin API, which answered 404 here — + // it is disabled on managed instances and behind a different path on others. + // Three ordinary queries and the bucketing done in JS needs none of that, and + // works on any Hasura the admin secret can reach. + const data = await gql( + `query ($tid: Int!, $lid: Int!) { + products(where: { tenantid: { _eq: $tid } }) { + productid productname categoryid subcategoryid + } + productlocations(where: { tenantid: { _eq: $tid }, locationid: { _eq: $lid } }) { + productid + } + productsubcategories(where: { categoryid: { _eq: 2 } }) { + subcategoryid subcategoryname + } + }`, + { tid, lid }, + ); + + const products = data.products ?? []; + const listed = new Set((data.productlocations ?? []).map((row) => row.productid)); + const realSubs = new Map( + (data.productsubcategories ?? []).map((row) => [row.subcategoryid, row.subcategoryname]), + ); + + if (products.length === 0) { + console.log(`Tenant ${tid} has no products at all.`); + return; + } + + const buckets = new Map(); + const examples = new Map(); + for (const product of products) { + let reason; + if (product.categoryid !== 2) { + reason = 'A. categoryid is not 2 — the app only asks for category 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 is not a real subcategory of 2 — silently dropped'; + } else { + reason = 'OK. should appear in the app'; + } + buckets.set(reason, (buckets.get(reason) ?? 0) + 1); + if (!examples.has(reason)) examples.set(reason, product); + } + + console.log(`${products.length} products on tenant ${tid}\n`); + const ordered = [...buckets.entries()].sort((a, b) => b[1] - a[1]); + for (const [reason, count] of ordered) { + const sample = examples.get(reason); + console.log(` ${String(count).padStart(5)} ${reason}`); + console.log( + ` e.g. ${sample.productname} (id ${sample.productid}, category ${sample.categoryid}, subcategory ${sample.subcategoryid})`, + ); + } + + console.log(`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none)'}`); +} + /* ── Dispatch ─────────────────────────────────────────────────────────────── */ const [command, ...rest] = process.argv.slice(2); @@ -232,6 +375,7 @@ const COMMANDS = { user: () => user(rest[0]), setpw: () => setpw(rest[0], rest[1]), sql: () => sql(rest.join(' ')), + appgap: () => appgap(rest[0], rest[1]), }; if (!command || !COMMANDS[command]) { @@ -243,9 +387,13 @@ if (!command || !COMMANDS[command]) { ' user show an account (never prints the password)', ' setpw set a password on an account that has none', ' sql "