initial commit
This commit is contained in:
262
scripts/appfix.mjs
Normal file
262
scripts/appfix.mjs
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Repairs the products the customer app cannot show.
|
||||
*
|
||||
* node scripts/appfix.mjs # dry run — prints, writes nothing
|
||||
* node scripts/appfix.mjs --apply # performs the repair
|
||||
* node scripts/appfix.mjs --apply --tenant 1135
|
||||
*
|
||||
* `appsweep.mjs` finds them; this puts them right. Same detection, so the two
|
||||
* cannot disagree about what is broken.
|
||||
*
|
||||
* ── What it does, and why it is shaped like this ─────────────────────────────
|
||||
*
|
||||
* The repair is "give the product a category", and for a long time there was no
|
||||
* way to do it. `products/update` writes only `productlocations.status` despite
|
||||
* its name, and `importcatalogueproduct` took an existing product down a branch
|
||||
* that corrected the PRICE and left the category alone — so re-importing, the
|
||||
* obvious fix, appeared to work and changed nothing.
|
||||
*
|
||||
* That branch now also calls `UpdateProductCategory`
|
||||
* (`services/productService.go`), which makes re-import the repair path. This
|
||||
* script drives it: for each orphan it re-sends the original import with a real
|
||||
* `categoryid`.
|
||||
*
|
||||
* REQUIRES THE FIXED BACKEND. Against the currently deployed one every call
|
||||
* returns 200 and nothing changes, which is exactly the failure that makes this
|
||||
* bug expensive — so the script verifies each product afterwards and reports
|
||||
* what actually moved rather than what it asked for.
|
||||
*
|
||||
* ── What it deliberately does not do ─────────────────────────────────────────
|
||||
*
|
||||
* It sends `quantity: 0` and `stocktype: "in"`, so no stock ledger entry is
|
||||
* written — `CreateProductLocation` only records stock when quantity > 0. The
|
||||
* products already have their stock and this must not add to it.
|
||||
*
|
||||
* It re-sends each product's EXISTING price, cost and tax, because the same
|
||||
* call updates pricing. Sending zeros would wipe the prices while fixing the
|
||||
* category.
|
||||
*/
|
||||
|
||||
const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app';
|
||||
const WEB = `${BASE}/live/api/v1/web`;
|
||||
|
||||
const apply = process.argv.includes('--apply');
|
||||
const tenantArg = process.argv.indexOf('--tenant');
|
||||
const onlyTenant = tenantArg > -1 ? Number(process.argv[tenantArg + 1]) : null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const response = await fetch(`${WEB}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return { ok: response.ok && payload?.status !== false, status: response.status, payload };
|
||||
}
|
||||
|
||||
async function tenantsWithOrphans() {
|
||||
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.set(t.tenantid, t);
|
||||
if (list.length < 200) break;
|
||||
}
|
||||
const out = [];
|
||||
for (const tenant of seen.values()) {
|
||||
if (onlyTenant && tenant.tenantid !== onlyTenant) continue;
|
||||
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
|
||||
const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []);
|
||||
const orphans = products.filter((p) => !p.categoryid);
|
||||
if (orphans.length === 0) continue;
|
||||
|
||||
/**
|
||||
* Where each orphan already sits.
|
||||
*
|
||||
* `getallproducts` is tenant-wide and carries NO locationid — the first
|
||||
* version of this read `p.locationid` off it, got undefined, sent 0, and
|
||||
* every repair came back "missing required field(s): locationid". Nothing
|
||||
* was written, which is the one good thing about that failure.
|
||||
*
|
||||
* The outlet matters beyond passing validation. Import writes a
|
||||
* productlocations row, so naming an outlet the product is NOT on would put
|
||||
* it on that shelf — silently extending the product's reach as a side
|
||||
* effect of a repair. Only an outlet where it is already stocked is safe:
|
||||
* there the upsert lands on the existing row.
|
||||
*/
|
||||
const locations = (await get('/tenants/gettenantlocations', { tenantid: tenant.tenantid })) ?? [];
|
||||
const placement = new Map();
|
||||
for (const loc of Array.isArray(locations) ? locations : []) {
|
||||
const rows = (await get('/products/getlocationproducts', {
|
||||
tenantid: tenant.tenantid,
|
||||
locationid: loc.locationid,
|
||||
pageno: 1,
|
||||
pagesize: 500,
|
||||
})) ?? [];
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
if (!placement.has(row.productid)) placement.set(row.productid, { loc, row });
|
||||
}
|
||||
}
|
||||
|
||||
out.push({ tenant, orphans, placement });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The category to file a product under.
|
||||
*
|
||||
* `gettenantcategories` is synthesised from the categories the tenant's own
|
||||
* products already use, so it is the tenant's real answer rather than the
|
||||
* master table's — which is unscoped and, for the tenants seen here, offered a
|
||||
* category (1001) that the app does not browse.
|
||||
*
|
||||
* Refusing rather than guessing when the tenant has none: a wrong category is
|
||||
* findable and fixable, but writing one at random across a live catalogue is
|
||||
* not something a repair script should decide.
|
||||
*/
|
||||
async function categoryFor(tenantid) {
|
||||
const rows = (await get('/products/gettenantcategories', { tenantid })) ?? [];
|
||||
const usable = (Array.isArray(rows) ? rows : []).filter((r) => r?.categoryid > 0);
|
||||
return usable[0]?.categoryid ?? null;
|
||||
}
|
||||
|
||||
const work = await tenantsWithOrphans();
|
||||
|
||||
if (work.length === 0) {
|
||||
console.log('Nothing to repair — no product is missing a category.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(apply ? 'APPLYING repairs\n' : 'DRY RUN — nothing will be written. Pass --apply.\n');
|
||||
|
||||
let repaired = 0;
|
||||
let unchanged = 0;
|
||||
let refused = 0;
|
||||
|
||||
for (const { tenant, orphans, placement } of work) {
|
||||
const categoryid = await categoryFor(tenant.tenantid);
|
||||
console.log(`${tenant.tenantid} ${tenant.tenantname} — ${orphans.length} to repair`);
|
||||
|
||||
if (!categoryid) {
|
||||
console.log(' SKIPPED: this tenant has no category of its own to file into.\n');
|
||||
refused += orphans.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const p of orphans) {
|
||||
const label = `${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`;
|
||||
|
||||
if (!p.productbrand || !p.catalogueid) {
|
||||
// Not imported from the global catalogue, so brand+catalogueid cannot
|
||||
// address it and re-import is not available. Says so rather than
|
||||
// reporting a success it did not achieve.
|
||||
console.log(` ${label} — SKIPPED: no catalogue reference to re-import from`);
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const at = placement.get(p.productid);
|
||||
if (!at) {
|
||||
console.log(` ${label} — SKIPPED: not stocked at any outlet, so there is no safe row to repair through`);
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* A product with no price must not be made visible.
|
||||
*
|
||||
* Repairing the category is what puts a product in front of shoppers, and
|
||||
* the app has no price floor — `GetProducts` filters on category and outlet
|
||||
* and nothing else. Fixing a product priced at 0 would not "restore" it; it
|
||||
* would publish a free one. Idhayam Sesame Oil 500ml (7083) is in exactly
|
||||
* this state, priced nowhere, and it wants a price before it wants a
|
||||
* category.
|
||||
*/
|
||||
const price = Number(p.retailprice ?? 0);
|
||||
if (!(price > 0)) {
|
||||
console.log(` ${label} — SKIPPED: no price set. Repairing this would list it at ₹0. Price it first.`);
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(
|
||||
` ${label} → categoryid ${categoryid} (via ${at.loc.locationname ?? at.loc.locationid}, price ₹${price} unchanged)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await post('/products/importcatalogueproduct', [
|
||||
{
|
||||
tenantid: tenant.tenantid,
|
||||
// An outlet the product ALREADY sits at, so the upsert lands on the
|
||||
// existing row instead of putting it on a new shelf. The category
|
||||
// itself is written to `products`, which is tenant-wide, so one call
|
||||
// fixes the product everywhere.
|
||||
locationid: at.loc.locationid,
|
||||
brand: p.productbrand,
|
||||
catalogueid: p.catalogueid,
|
||||
categoryid,
|
||||
subcategoryid: p.subcategoryid ?? 0,
|
||||
// Zero, so CreateProductLocation writes no stock ledger entry — it only
|
||||
// records stock when quantity > 0. These products already have their
|
||||
// stock and a repair must not add to it.
|
||||
quantity: 0,
|
||||
stocktype: 'in',
|
||||
status: at.row.productstatus || p.productstatus || 'Active',
|
||||
// The product's OWN current values, re-sent unchanged. The same call
|
||||
// updates pricing, and the location upsert sets productlocations.price
|
||||
// from retailprice — verified equal for every product being repaired
|
||||
// here, so this round-trips rather than overwriting an outlet price.
|
||||
retailprice: price,
|
||||
productcost: p.productcost ?? 0,
|
||||
taxpercent: p.taxpercent ?? 0,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!result.ok) {
|
||||
console.log(` ${label} — FAILED: HTTP ${result.status} ${result.payload?.message ?? ''}`);
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verified, not assumed. The whole reason this bug survived is that the
|
||||
// call that was supposed to fix it returned success and did nothing.
|
||||
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
|
||||
const after = (Array.isArray(groups) ? groups : [])
|
||||
.flatMap((g) => g?.products ?? [])
|
||||
.find((x) => x.productid === p.productid);
|
||||
|
||||
if (after?.categoryid > 0) {
|
||||
console.log(` ${label} → categoryid ${after.categoryid} ✓`);
|
||||
repaired++;
|
||||
} else {
|
||||
console.log(
|
||||
` ${label} — NO CHANGE: the call succeeded but the category is still 0.` +
|
||||
' The backend fix is not deployed.',
|
||||
);
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`repaired ${repaired} · unchanged ${unchanged} · skipped ${refused}`);
|
||||
if (unchanged > 0) {
|
||||
console.log(
|
||||
'\nProducts reported NO CHANGE need the backend fix deployed' +
|
||||
' (services/productService.go — re-import must call UpdateProductCategory).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
193
scripts/appgap.mjs
Normal file
193
scripts/appgap.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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)'}`,
|
||||
);
|
||||
246
scripts/appsweep.mjs
Normal file
246
scripts/appsweep.mjs
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
91
scripts/checkHealthPanel.ts
Normal file
91
scripts/checkHealthPanel.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* What the health panel would render, for real products, against the live
|
||||
* service.
|
||||
*
|
||||
* Not a test — the tests pin behaviour against a frozen fixture. This runs the
|
||||
* SAME functions the panel calls against whatever the service is returning
|
||||
* right now, which is the only way to catch the service changing under us.
|
||||
*
|
||||
* npx tsx scripts/checkHealthPanel.ts
|
||||
*/
|
||||
|
||||
import { nutritionApi, __resolveBrand } from '../src/api/nutrition';
|
||||
import { facts, present, BAND_LABEL } from '../src/features/store-admin/healthScore';
|
||||
|
||||
const CASES: { label: string; brand: string; imageId: string; expect: string }[] = [
|
||||
{
|
||||
label: 'Cadbury 5 Star 200g',
|
||||
brand: 'Cadbury',
|
||||
imageId: 'cadbury_cadbury_5_star_200g',
|
||||
expect: 'a score, with a low-confidence caveat',
|
||||
},
|
||||
{
|
||||
label: 'Godrej Hit Spray (INSECTICIDE)',
|
||||
brand: 'Godrej',
|
||||
imageId: 'godrej_hit_spray_1101d017',
|
||||
expect: 'NO score — blocked by the edibility guard',
|
||||
},
|
||||
{
|
||||
label: 'Naga Sooji',
|
||||
brand: 'Naga',
|
||||
imageId: 'naga_naga_sooji_100g',
|
||||
expect: 'a high score, allergen declared',
|
||||
},
|
||||
{
|
||||
label: 'Aachi Baby Fryums 100g (a real merchant product)',
|
||||
brand: 'Aachi',
|
||||
imageId: 'aachi_aachi_baby_fryums_100g',
|
||||
expect: 'known but unscored',
|
||||
},
|
||||
];
|
||||
|
||||
const line = (s = '') => console.log(s);
|
||||
|
||||
for (const testCase of CASES) {
|
||||
line();
|
||||
line('─'.repeat(72));
|
||||
line(`${testCase.label}`);
|
||||
line(`expected: ${testCase.expect}`);
|
||||
line('─'.repeat(72));
|
||||
|
||||
const raw = await nutritionApi.forProduct(testCase.brand, testCase.imageId);
|
||||
const shown = present(raw);
|
||||
|
||||
if (shown.isEmpty) {
|
||||
line(' → "No health score available for this product yet."');
|
||||
continue;
|
||||
}
|
||||
if (shown.isPending) {
|
||||
line(' → "This product is in the catalogue but has not been scored yet."');
|
||||
line(` (service sent health_score=${raw?.health_score}, category="${raw?.category}")`);
|
||||
continue;
|
||||
}
|
||||
|
||||
line(` SCORE ${shown.display}/100 ${shown.band ? BAND_LABEL[shown.band] : ''}`);
|
||||
for (const good of shown.good) line(` ✓ ${good}`);
|
||||
for (const caution of shown.cautions) line(` ! ${caution}`);
|
||||
if (shown.tags.length) line(` TAGS ${shown.tags.join(' · ')}`);
|
||||
if (shown.allergens.length) line(` ALLERGENS Contains ${shown.allergens.join(', ')}`);
|
||||
else if (shown.allergensUnconfirmed) line(' ALLERGENS not confirmed — check the pack');
|
||||
const rows = facts(raw);
|
||||
if (rows.length) line(` PER 100g ${rows.map((r) => `${r.label} ${r.value}`).join(' · ')}`);
|
||||
if (shown.caveat) line(` CAVEAT ${shown.caveat}`);
|
||||
if (shown.source) line(` SOURCE ${shown.source.label}`);
|
||||
}
|
||||
|
||||
line();
|
||||
|
||||
/* ── Brand vocabularies ──────────────────────────────────────────────────── */
|
||||
/* Our catalogue writes snake_case, theirs writes Title Case with a separator
|
||||
that is sometimes a space and sometimes a hyphen. A mismatch returns
|
||||
health_score: null — indistinguishable from an unscored product — so this
|
||||
checks the resolution rather than trusting it. */
|
||||
|
||||
line('─'.repeat(72));
|
||||
line('Brand resolution: our spelling → theirs');
|
||||
line('─'.repeat(72));
|
||||
for (const ours of ['cadbury', 'coca_cola', 'brooke_bond', '24_mantra', 'colgate_palmolive', 'aachi']) {
|
||||
const theirs = await __resolveBrand(ours);
|
||||
line(` ${ours.padEnd(20)} → ${theirs}`);
|
||||
}
|
||||
line();
|
||||
89
scripts/checkOptimiser.ts
Normal file
89
scripts/checkOptimiser.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* The route-plan chain, run against the live optimiser.
|
||||
*
|
||||
* Not a test — the unit tests pin `routePlan.ts` against a frozen fixture. This
|
||||
* calls the real service with real order rows and pushes the answer through the
|
||||
* same functions the drawer uses, which is the only way to notice the service
|
||||
* changing shape under us.
|
||||
*
|
||||
* npm run check:optimiser
|
||||
*/
|
||||
|
||||
import { optimiserApi } from '../src/api/optimiser';
|
||||
import type { OrderRow, RiderInfo } from '../src/api/types';
|
||||
import {
|
||||
applyReconcile,
|
||||
commitProblem,
|
||||
dirtyRiders,
|
||||
planFromSequence,
|
||||
splitRoutable,
|
||||
planKms,
|
||||
reorderStops,
|
||||
unplaced,
|
||||
} from '../src/features/store-admin/routePlan';
|
||||
|
||||
const line = (s = '') => console.log(s);
|
||||
|
||||
/** Real Suriya Store geography: the RS Puram branch out to four drops. */
|
||||
const ORDERS: OrderRow[] = [
|
||||
{ orderheaderid: 1, orderid: 'N-1', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0284', deliverylong: '77.0120', deliverycustomer: 'Peelamedu' },
|
||||
{ orderheaderid: 2, orderid: 'N-2', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0050', deliverylong: '76.9508', deliverycustomer: 'RS Puram' },
|
||||
{ orderheaderid: 3, orderid: 'N-3', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '10.9877', deliverylong: '76.9620', deliverycustomer: 'Ukkadam' },
|
||||
{ orderheaderid: 4, orderid: 'N-4', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0183', deliverylong: '76.9724', deliverycustomer: 'Gandhipuram' },
|
||||
// No coordinates — must come back as unplaced rather than vanishing.
|
||||
{ orderheaderid: 5, orderid: 'N-5', deliverycustomer: 'No location on file' },
|
||||
] as OrderRow[];
|
||||
|
||||
const RIDER: RiderInfo = { userid: 9701, fullname: 'Meera Raj', contactno: '9000000001' };
|
||||
|
||||
line('─'.repeat(74));
|
||||
line('1 · SEQUENCE — send in a deliberately bad order, see what comes back');
|
||||
line('─'.repeat(74));
|
||||
|
||||
const { routable, unroutable } = splitRoutable(ORDERS);
|
||||
const stops = await optimiserApi.sequence(routable);
|
||||
let plan = planFromSequence(stops, RIDER);
|
||||
|
||||
for (const stop of plan.riders[0]?.orders ?? []) {
|
||||
line(
|
||||
` ${String(stop.step).padStart(2)} ${(stop.orderid ?? '').padEnd(5)} ` +
|
||||
`${(stop.deliverycustomer ?? '').padEnd(22)} ` +
|
||||
`${String(stop.previouskms ?? '').padStart(5)} km ` +
|
||||
`cum ${String(stop.cumulativekms ?? '').padStart(5)} km ` +
|
||||
`eta ${stop.eta ?? '-'}m actualkms ${stop.actualkms ?? '-'}`,
|
||||
);
|
||||
}
|
||||
line(` total ${planKms(plan).toFixed(1)} km`);
|
||||
|
||||
const missed = [...unroutable, ...unplaced(routable, stops)];
|
||||
line(` unplaced: ${missed.length ? missed.map((o) => o.orderid).join(', ') : 'none'}`);
|
||||
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`);
|
||||
|
||||
line();
|
||||
line('─'.repeat(74));
|
||||
line('2 · EDIT — move the first stop to last, which breaks the step numbers');
|
||||
line('─'.repeat(74));
|
||||
|
||||
plan = reorderStops(plan, RIDER.userid, 0, (plan.riders[0]?.orders.length ?? 1) - 1);
|
||||
line(` dirty rounds: ${[...plan.dirty].join(', ')}`);
|
||||
line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')} <- out of order`);
|
||||
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'NO'}`);
|
||||
line(` reason: ${commitProblem(plan)}`);
|
||||
|
||||
line();
|
||||
line('─'.repeat(74));
|
||||
line('3 · RECONCILE — the service repairs the step numbers');
|
||||
line('─'.repeat(74));
|
||||
|
||||
try {
|
||||
const response = await optimiserApi.reconcile(dirtyRiders(plan));
|
||||
plan = applyReconcile(plan, response);
|
||||
line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')}`);
|
||||
line(` dirty rounds: ${plan.dirty.size === 0 ? 'none' : [...plan.dirty].join(', ')}`);
|
||||
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`);
|
||||
} catch (error) {
|
||||
line(` reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
line(` commit still blocked? ${commitProblem(plan) !== '' ? 'YES — correct' : 'NO — WRONG'}`);
|
||||
}
|
||||
|
||||
line();
|
||||
434
scripts/contract.mjs
Normal file
434
scripts/contract.mjs
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* The contract check.
|
||||
*
|
||||
* Signs in once, calls every endpoint the console reads, and reports what came
|
||||
* back: the HTTP status, the envelope, whether `details` is an array or an
|
||||
* object or null, how many rows, and — the part that matters — the keys on the
|
||||
* first row against the keys `src/api/types.ts` says to expect.
|
||||
*
|
||||
* This exists because field-name drift is invisible until a real payload
|
||||
* arrives, and it is the single most likely way connecting to the backend goes
|
||||
* wrong. It has already happened once from fixtures alone: POS health returns
|
||||
* `terminal_id` while the sales split returns `terminalid`, and an index
|
||||
* signature on the type let the wrong one typecheck in silence.
|
||||
*
|
||||
* READ-ONLY. Nothing here writes. Every create, update and import is left to a
|
||||
* person on a scratch tenant, because several of them are unscoped and one of
|
||||
* them moves stock.
|
||||
*
|
||||
* Run:
|
||||
* npm run contract (prompts for the password, hidden)
|
||||
*
|
||||
* Or, for CI, set NEARLE_EMAIL and NEARLE_PASSWORD in the environment. Neither
|
||||
* is ever written into this file — see the note beside EMAIL below.
|
||||
*
|
||||
* Plain JavaScript on purpose: it runs with the node you already have, with no
|
||||
* install step and no TypeScript loader in the way.
|
||||
*
|
||||
* The credentials are read from the environment and never printed, logged or
|
||||
* written to a file. Put them in front of the command rather than in a script,
|
||||
* and they stay out of your shell history if your shell is configured for it.
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
const BASE = process.env['NEARLE_API'] ?? 'https://fiesta.nearle.app';
|
||||
|
||||
/**
|
||||
* `/web/pos`, not `/pos`.
|
||||
*
|
||||
* The `/v1/pos` group sits behind the terminal's session guard; the console's
|
||||
* copies of the same reads are registered under `/v1/web/pos`. Sweeping the
|
||||
* wrong one would report a surface the console never calls.
|
||||
*/
|
||||
const WEB = '/live/api/v1/web';
|
||||
const POS = '/live/api/v1/web/pos';
|
||||
const MOB = '/live/api/v1/mob';
|
||||
|
||||
/**
|
||||
* The account to sweep with.
|
||||
*
|
||||
* The email defaults because it is not a secret. The PASSWORD is never
|
||||
* defaulted and never written into this file: a password in source is
|
||||
* committed, synced to every machine that clones the repo, and survives in the
|
||||
* history after it is changed. It is read from the environment if set, and
|
||||
* otherwise typed at the prompt below, where it is not echoed and does not
|
||||
* reach the shell history.
|
||||
*/
|
||||
const EMAIL = process.env['NEARLE_EMAIL'] ?? 'care@nearle.in';
|
||||
|
||||
/** Reads a line without echoing it. */
|
||||
function askHidden(question) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
const onData = (char) => {
|
||||
// Stop echoing everything except the newline that ends the answer.
|
||||
if (char.toString() !== '\n' && char.toString() !== '\r' && char.toString() !== '\u0004') {
|
||||
process.stdout.write('\u001b[2K\u001b[200D' + question + '*'.repeat(rl.line.length));
|
||||
}
|
||||
};
|
||||
process.stdin.on('data', onData);
|
||||
rl.question(question, (answer) => {
|
||||
process.stdin.off('data', onData);
|
||||
rl.close();
|
||||
process.stdout.write('\n');
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const PASSWORD =
|
||||
process.env['NEARLE_PASSWORD'] ?? (await askHidden(`Password for ${EMAIL}: `));
|
||||
|
||||
if (!PASSWORD) {
|
||||
console.error('No password given — nothing to sign in with.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function call(path, init = {}) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(init.params ?? {})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const query = search.toString();
|
||||
const response = await fetch(`${BASE}${path}${query ? `?${query}` : ''}`, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: init.body
|
||||
? { Accept: 'application/json', 'Content-Type': 'application/json' }
|
||||
: { Accept: 'application/json' },
|
||||
...(init.body ? { body: JSON.stringify(init.body) } : {}),
|
||||
});
|
||||
let envelope = {};
|
||||
try {
|
||||
envelope = await response.json();
|
||||
} catch {
|
||||
envelope = { message: 'not JSON' };
|
||||
}
|
||||
return { http: response.status, envelope };
|
||||
}
|
||||
|
||||
/**
|
||||
* A call that reports a dead host rather than crashing the run.
|
||||
*
|
||||
* A wrong `NEARLE_API`, a VPN that is not up, or one endpoint timing out should
|
||||
* leave the other twenty-four results on screen — a stack trace at check four
|
||||
* tells you nothing about checks five to twenty-five.
|
||||
*/
|
||||
async function attempt(path, init = {}) {
|
||||
try {
|
||||
return await call(path, init);
|
||||
} catch (cause) {
|
||||
return {
|
||||
http: 0,
|
||||
envelope: { status: false, message: `could not reach the server (${String(cause)})` },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sign in ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const login = await attempt(`${WEB}/users/applogin`, {
|
||||
method: 'POST',
|
||||
// `configid` is not optional: the lookup is `WHERE authname = ? AND configid = ?`.
|
||||
body: { authname: EMAIL, password: PASSWORD, configid: 1 },
|
||||
});
|
||||
|
||||
if (login.envelope.status !== true || !login.envelope.details) {
|
||||
console.error(
|
||||
`Sign-in failed — HTTP ${login.http}, code ${login.envelope.code}: ${login.envelope.message}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const me = login.envelope.details;
|
||||
const tenantid = Number(me['tenantid'] ?? 0);
|
||||
const locationid = Number(me['locationid'] ?? 0);
|
||||
const issuperadmin = me['issuperadmin'] === true;
|
||||
|
||||
console.log('── signed in ─────────────────────────────────────────────');
|
||||
console.log(`userid ${me['userid']} · roleid ${me['roleid']} · issuperadmin ${issuperadmin}`);
|
||||
console.log(`tenantid ${tenantid} · locationid ${locationid} · ${me['locationname'] ?? '—'}`);
|
||||
console.log('login keys:', Object.keys(me).sort().join(', '));
|
||||
console.log('');
|
||||
|
||||
/**
|
||||
* A tenant and a branch to probe the scoped endpoints with.
|
||||
*
|
||||
* A super admin has neither of their own, so one is borrowed from the platform
|
||||
* list. Override with NEARLE_TENANT / NEARLE_LOCATION to aim at a specific one.
|
||||
*/
|
||||
let probeTenant = Number(process.env['NEARLE_TENANT'] ?? 0) || tenantid;
|
||||
let probeLocation = Number(process.env['NEARLE_LOCATION'] ?? 0) || locationid;
|
||||
|
||||
if (!probeTenant) {
|
||||
const tenants = await attempt(`${WEB}/tenants/getalltenants`, {
|
||||
params: { pageno: 1, pagesize: 1 },
|
||||
});
|
||||
probeTenant = Number(tenants.envelope.details?.[0]?.['tenantid'] ?? 0);
|
||||
}
|
||||
if (probeTenant && !probeLocation) {
|
||||
const locations = await attempt(`${WEB}/tenants/gettenantlocations`, {
|
||||
params: { tenantid: probeTenant },
|
||||
});
|
||||
probeLocation = Number(locations.envelope.details?.[0]?.['locationid'] ?? 0);
|
||||
}
|
||||
|
||||
console.log(`probing with tenantid ${probeTenant} · locationid ${probeLocation}\n`);
|
||||
|
||||
/* ── What we expect ──────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The keys each row should carry, taken from `src/api/types.ts`.
|
||||
*
|
||||
* Only the ones the console actually reads are listed — a backend that returns
|
||||
* MORE than this is fine and normal, and is reported as extras rather than as a
|
||||
* failure. What matters is anything missing.
|
||||
*/
|
||||
const CHECKS = [
|
||||
{
|
||||
name: 'tenants/getalltenants',
|
||||
path: `${WEB}/tenants/getalltenants`,
|
||||
params: { pageno: 1, pagesize: 5 },
|
||||
expect: ['tenantid', 'tenantname', 'locationid', 'locationname', 'status'],
|
||||
},
|
||||
{
|
||||
name: 'tenants/search?pending',
|
||||
path: `${WEB}/tenants/search`,
|
||||
params: { status: 'pending' },
|
||||
expect: ['tenantid', 'tenantname'],
|
||||
},
|
||||
{
|
||||
name: 'tenants/gettenantlocations',
|
||||
path: `${WEB}/tenants/gettenantlocations`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['locationid', 'tenantid', 'locationname', 'status'],
|
||||
},
|
||||
{
|
||||
name: 'utils/getappcategories',
|
||||
path: `${WEB}/utils/getappcategories`,
|
||||
expect: ['categoryid', 'categoryname'],
|
||||
},
|
||||
{
|
||||
name: 'orders/getlocationsummary',
|
||||
path: `${WEB}/orders/getlocationsummary`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['locationid', 'locationname', 'total', 'delivered', 'cancelled'],
|
||||
},
|
||||
{
|
||||
name: 'orders/getordersummary',
|
||||
path: `${WEB}/orders/getordersummary`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['total', 'delivered', 'cancelled'],
|
||||
},
|
||||
{
|
||||
name: 'catalogue/getbrands',
|
||||
path: `${WEB}/catalogue/getbrands`,
|
||||
expect: ['brand', 'product_count'],
|
||||
},
|
||||
{
|
||||
name: 'catalogue/getproducts',
|
||||
path: `${WEB}/catalogue/getproducts`,
|
||||
params: { pageno: 1, pagesize: 5 },
|
||||
expect: ['id', 'brand', 'product_name'],
|
||||
},
|
||||
{
|
||||
name: 'products/getimportedcatalogueproducts',
|
||||
path: `${WEB}/products/getimportedcatalogueproducts`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['brand', 'catalogueid'],
|
||||
},
|
||||
{
|
||||
name: 'products/getproductcategories',
|
||||
path: `${WEB}/products/getproductcategories`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['categoryid', 'categoryname'],
|
||||
},
|
||||
{
|
||||
name: 'products/gettenantcategories',
|
||||
path: `${WEB}/products/gettenantcategories`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['categoryid', 'categoryname'],
|
||||
},
|
||||
{
|
||||
name: 'products/getlocationproducts',
|
||||
path: `${WEB}/products/getlocationproducts`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||
needs: 'location',
|
||||
expect: ['productid', 'productname', 'price', 'publishedat', 'status'],
|
||||
},
|
||||
{
|
||||
name: 'products/getallproducts',
|
||||
path: `${WEB}/products/getallproducts`,
|
||||
params: { tenantid: probeTenant, pageno: 1, pagesize: 5 },
|
||||
needs: 'tenant',
|
||||
expect: ['productid', 'productname'],
|
||||
},
|
||||
{
|
||||
name: 'products/getstockstatement',
|
||||
path: `${WEB}/products/getstockstatement`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||
needs: 'location',
|
||||
expect: ['productid', 'opening', 'credit', 'debit', 'closing'],
|
||||
},
|
||||
{
|
||||
name: 'products/getstockrequests',
|
||||
path: `${WEB}/products/getstockrequests`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||
needs: 'tenant',
|
||||
expect: ['requestid', 'productid', 'qty', 'status'],
|
||||
},
|
||||
{
|
||||
name: 'products/getsaletemplate',
|
||||
path: `${WEB}/products/getsaletemplate`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||
needs: 'tenant',
|
||||
expect: ['tenantid', 'locations', 'products'],
|
||||
},
|
||||
{
|
||||
name: 'customers/gettenantcustomers',
|
||||
path: `${WEB}/customers/gettenantcustomers`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||
needs: 'tenant',
|
||||
expect: ['customerid', 'firstname', 'contactno'],
|
||||
},
|
||||
{
|
||||
name: 'orders/getorders',
|
||||
path: `${WEB}/orders/tenant/getorders`,
|
||||
params: {
|
||||
tenantid: probeTenant,
|
||||
locationid: probeLocation,
|
||||
fromdate: isoDaysAgo(30),
|
||||
todate: isoDaysAgo(0),
|
||||
pageno: 1,
|
||||
pagesize: 5,
|
||||
},
|
||||
needs: 'tenant',
|
||||
expect: ['orderheaderid', 'orderstatus'],
|
||||
},
|
||||
{
|
||||
name: 'deliveries/getdeliveries',
|
||||
path: `${WEB}/deliveries/getdeliveries`,
|
||||
params: {
|
||||
tenantid: probeTenant,
|
||||
locationid: probeLocation,
|
||||
fromdate: isoDaysAgo(30),
|
||||
todate: isoDaysAgo(0),
|
||||
pageno: 1,
|
||||
pagesize: 5,
|
||||
},
|
||||
needs: 'tenant',
|
||||
expect: ['orderheaderid', 'orderstatus'],
|
||||
},
|
||||
{
|
||||
name: 'pos/sales/summary',
|
||||
path: `${POS}/sales/summary`,
|
||||
params: { locationid: probeLocation, fromdate: isoDaysAgo(7), todate: isoDaysAgo(0) },
|
||||
needs: 'location',
|
||||
expect: ['billcount', 'grosssales', 'taxcollected'],
|
||||
},
|
||||
{
|
||||
name: 'pos/sales',
|
||||
path: `${POS}/sales`,
|
||||
params: { locationid: probeLocation, pageno: 0, pagesize: 5 },
|
||||
needs: 'location',
|
||||
expect: ['bills', 'total'],
|
||||
},
|
||||
{
|
||||
name: 'pos/health/location',
|
||||
path: `${POS}/health/location`,
|
||||
params: { location_id: probeLocation },
|
||||
needs: 'location',
|
||||
expect: ['total', 'online', 'terminals'],
|
||||
},
|
||||
{
|
||||
name: 'tenants/getposusers',
|
||||
path: `${WEB}/tenants/getposusers`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||
needs: 'location',
|
||||
expect: ['users', 'location_id'],
|
||||
},
|
||||
{
|
||||
name: 'tenants/getstaffs (MOB)',
|
||||
path: `${MOB}/tenants/getstaffs`,
|
||||
params: { tenantid: probeTenant },
|
||||
needs: 'tenant',
|
||||
expect: ['userid', 'rolename', 'firstname'],
|
||||
},
|
||||
{ name: 'tenants/posroles', path: `${WEB}/tenants/posroles`, expect: ['role_id', 'role'] },
|
||||
{
|
||||
name: 'tenants/getstaffshifts',
|
||||
path: `${WEB}/tenants/getstaffshifts`,
|
||||
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||
needs: 'location',
|
||||
expect: ['shifts', 'location_id'],
|
||||
},
|
||||
];
|
||||
|
||||
function isoDaysAgo(days) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/* ── Run ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
let mismatches = 0;
|
||||
let unreachable = 0;
|
||||
|
||||
for (const check of CHECKS) {
|
||||
if (check.needs === 'tenant' && !probeTenant) {
|
||||
console.log(`SKIP ${check.name} — no tenant to probe with`);
|
||||
continue;
|
||||
}
|
||||
if (check.needs === 'location' && !probeLocation) {
|
||||
console.log(`SKIP ${check.name} — no location to probe with`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { http, envelope } = await attempt(check.path, { params: check.params });
|
||||
const payload = envelope.details ?? envelope.data;
|
||||
|
||||
const shape = Array.isArray(payload)
|
||||
? `array(${payload.length})`
|
||||
: payload === null || payload === undefined
|
||||
? 'null'
|
||||
: typeof payload;
|
||||
|
||||
// The row to inspect: the first element of a list, or the object itself.
|
||||
const row = Array.isArray(payload) ? payload[0] : payload;
|
||||
const keys = row && typeof row === 'object' ? Object.keys(row) : [];
|
||||
const missing = check.expect.filter((key) => !keys.includes(key));
|
||||
|
||||
const ok = envelope.status !== false && http < 400 && missing.length === 0;
|
||||
if (!ok) mismatches += 1;
|
||||
if (http >= 400 || envelope.status === false) unreachable += 1;
|
||||
|
||||
console.log(
|
||||
`${ok ? 'OK ' : 'CHECK'} ${check.name.padEnd(38)} http ${http} · code ${envelope.code ?? '—'} · ${shape}`,
|
||||
);
|
||||
if (envelope.status === false || http >= 400) {
|
||||
console.log(` message: ${envelope.message ?? '(none)'}`);
|
||||
}
|
||||
if (missing.length > 0 && keys.length > 0) {
|
||||
console.log(` MISSING: ${missing.join(', ')}`);
|
||||
console.log(` got: ${keys.sort().join(', ')}`);
|
||||
}
|
||||
if (keys.length === 0 && shape !== 'null' && !Array.isArray(payload)) {
|
||||
console.log(` payload: ${JSON.stringify(payload).slice(0, 160)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`${CHECKS.length} checked · ${mismatches} to look at · ${unreachable} refused`);
|
||||
console.log(
|
||||
mismatches === 0
|
||||
? 'Every endpoint answered in the shape the console expects.'
|
||||
: 'Anything marked CHECK either refused the call or is missing a key the console reads.',
|
||||
);
|
||||
399
scripts/db.mjs
Normal file
399
scripts/db.mjs
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Direct database access, through Hasura.
|
||||
*
|
||||
* A scratchpad for reading and fixing rows that no screen exposes — setting a
|
||||
* password on an account that was spawned without one, flipping a status,
|
||||
* checking what the API is actually reading. It talks to the Hasura instance
|
||||
* the old console proxies to (`api.workolik.com`), using the admin secret from
|
||||
* `daily_merchant_web/.env`, which is gitignored and stays there.
|
||||
*
|
||||
* node scripts/db.mjs tables
|
||||
* node scripts/db.mjs user care@nearle.in
|
||||
* node scripts/db.mjs setpw care@nearle.in <password>
|
||||
* node scripts/db.mjs sql "select userid, authname from app_users limit 5"
|
||||
*
|
||||
* The secret is read from disk or the environment and never printed, never
|
||||
* written anywhere, and never passed on the command line.
|
||||
*
|
||||
* ── Read this before using `setpw` ────────────────────────────────────────
|
||||
* This points at PRODUCTION. Every write here is immediate and unversioned.
|
||||
* `setpw` refuses to run unless the account's password column is already
|
||||
* empty, so it can only ever complete a setup that was never finished — it
|
||||
* cannot overwrite a working login. Lift that guard only deliberately.
|
||||
*
|
||||
* Passwords in `app_users` are stored in clear. That is a property of this
|
||||
* backend, not of this script; anything written here is readable by anyone
|
||||
* with database access.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* 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 = [
|
||||
resolve(HERE, '../../../nearle-daily/daily_merchant_web/.env'),
|
||||
resolve(HERE, '../../daily_merchant_web/.env'),
|
||||
'D:/nearle-daily/daily_merchant_web/.env',
|
||||
];
|
||||
|
||||
function readSecret() {
|
||||
if (process.env.HASURA_ADMIN_SECRET) return process.env.HASURA_ADMIN_SECRET;
|
||||
|
||||
for (const path of ENV_CANDIDATES) {
|
||||
if (!existsSync(path)) continue;
|
||||
const line = readFileSync(path, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.find((row) => row.startsWith('HASURA_ADMIN_SECRET='));
|
||||
if (!line) continue;
|
||||
const value = line.slice('HASURA_ADMIN_SECRET='.length).trim().replace(/^["']|["']$/g, '');
|
||||
if (value) return value;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'No admin secret found.\n' +
|
||||
'Expected HASURA_ADMIN_SECRET in one of:\n' +
|
||||
ENV_CANDIDATES.map((p) => ` ${p}`).join('\n') +
|
||||
'\nor set it in the environment for this command.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SECRET = readSecret();
|
||||
|
||||
async function gql(query, variables = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
} catch (cause) {
|
||||
console.error(`Could not reach ${ENDPOINT} — ${cause.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!payload) {
|
||||
console.error(`Malformed response (HTTP ${response.status})`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (payload.errors) {
|
||||
for (const error of payload.errors) console.error(`✗ ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/* ── Commands ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Every table Hasura has tracked. Start here if a query says "field not found". */
|
||||
async function tables() {
|
||||
const data = await gql(`{ __schema { queryType { fields { name } } } }`);
|
||||
const names = data.__schema.queryType.fields
|
||||
.map((field) => field.name)
|
||||
.filter((name) => !name.endsWith('_aggregate') && !name.endsWith('_by_pk'))
|
||||
.sort();
|
||||
console.log(names.join('\n'));
|
||||
console.log(`\n${names.length} tables`);
|
||||
}
|
||||
|
||||
const USER_FIELDS = `userid authname firstname lastname contactno roleid status tenantid locationid configid`;
|
||||
|
||||
async function findUser(email) {
|
||||
const data = await gql(
|
||||
`query ($email: String!) {
|
||||
app_users(where: { authname: { _eq: $email } }) { ${USER_FIELDS} password }
|
||||
}`,
|
||||
{ email },
|
||||
);
|
||||
return data.app_users ?? [];
|
||||
}
|
||||
|
||||
async function user(email) {
|
||||
const rows = await findUser(email);
|
||||
if (rows.length === 0) {
|
||||
console.log(`No account with authname "${email}".`);
|
||||
return;
|
||||
}
|
||||
for (const row of rows) {
|
||||
// The password itself is never printed — only whether one exists, which is
|
||||
// the only thing anyone needs to know from here.
|
||||
const { password, ...rest } = row;
|
||||
console.log({ ...rest, haspassword: String(password ?? '').trim() !== '' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a password setup that was never finished.
|
||||
*
|
||||
* Refuses if a password is already set. An account that can sign in must not
|
||||
* be changeable from a scratchpad — that is a support action with a person
|
||||
* behind it, not a one-liner.
|
||||
*/
|
||||
async function setpw(email, password) {
|
||||
if (!password || password.length < 6) {
|
||||
console.error('Password must be at least 6 characters (the backend enforces this too).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rows = await findUser(email);
|
||||
if (rows.length === 0) {
|
||||
console.error(`No account with authname "${email}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (rows.length > 1) {
|
||||
console.error(
|
||||
`${rows.length} accounts share that email (configid ${rows.map((r) => r.configid).join(', ')}).\n` +
|
||||
'Refusing to guess. Use `sql` with an explicit userid.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
if (String(row.password ?? '').trim() !== '') {
|
||||
console.error(
|
||||
`userid ${row.userid} already has a password. This command only completes an unfinished setup.\n` +
|
||||
'To reset a working login, do it deliberately with `sql`.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (row.roleid === 7 || row.roleid === 8) {
|
||||
console.error(
|
||||
`userid ${row.userid} is a till account (roleid ${row.roleid}). Those sign in at the terminal with a PIN, not here.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await gql(
|
||||
`mutation ($userid: Int!, $password: String!) {
|
||||
update_app_users(where: { userid: { _eq: $userid } }, _set: { password: $password }) {
|
||||
affected_rows
|
||||
}
|
||||
}`,
|
||||
{ userid: row.userid, password },
|
||||
);
|
||||
|
||||
const affected = data.update_app_users?.affected_rows ?? 0;
|
||||
if (affected !== 1) {
|
||||
console.error(`Expected to update 1 row, updated ${affected}. Nothing assumed — check manually.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✓ Password set on userid ${row.userid} (${email}), roleid ${row.roleid}, tenantid ${row.tenantid}.`,
|
||||
);
|
||||
console.log(' Sign in at the console with it now.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbitrary read-only SQL, via Hasura's `run_sql`.
|
||||
*
|
||||
* Reads only. A statement that writes is refused here — writes go through a
|
||||
* named command above, where they can carry their own guard.
|
||||
*/
|
||||
async function sql(statement) {
|
||||
if (/^\s*(insert|update|delete|drop|alter|truncate|create)\b/i.test(statement)) {
|
||||
console.error('This command runs reads only. Add a named command for a write.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const endpoint = ENDPOINT.replace(/\/v1\/graphql$/, '/v2/query');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
|
||||
body: JSON.stringify({
|
||||
type: 'run_sql',
|
||||
args: { source: 'default', sql: statement, read_only: true },
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload) {
|
||||
console.error(payload?.error ?? `HTTP ${response.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rows = payload.result ?? [];
|
||||
for (const row of rows) console.log(row.join('\t'));
|
||||
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 <tenantid> <locationid>
|
||||
*
|
||||
* `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 <tenantid> <locationid>');
|
||||
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);
|
||||
|
||||
const COMMANDS = {
|
||||
tables: () => tables(),
|
||||
user: () => user(rest[0]),
|
||||
setpw: () => setpw(rest[0], rest[1]),
|
||||
sql: () => sql(rest.join(' ')),
|
||||
appgap: () => appgap(rest[0], rest[1]),
|
||||
};
|
||||
|
||||
if (!command || !COMMANDS[command]) {
|
||||
console.log(
|
||||
[
|
||||
'node scripts/db.mjs <command>',
|
||||
'',
|
||||
' tables every table Hasura has tracked',
|
||||
' user <email> show an account (never prints the password)',
|
||||
' setpw <email> <password> set a password on an account that has none',
|
||||
' sql "<select ...>" read-only SQL',
|
||||
' appgap <tenant> <outlet> why the app shows fewer products than the console',
|
||||
].join('\n'),
|
||||
);
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
|
||||
// Locate Hasura before anything talks to it.
|
||||
await resolveEndpoint();
|
||||
|
||||
await COMMANDS[command]();
|
||||
167
scripts/mapPreview.mjs
Normal file
167
scripts/mapPreview.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Build a standalone page that renders the REAL dispatch map with mock stops.
|
||||
*
|
||||
* ── Why this exists ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* A leaflet map cannot be checked by anything else in this repo. The pure tests
|
||||
* never mount it, `renderToString` never runs the effect that builds it, and
|
||||
* the jsdom tests can only COUNT what it produced — none of them can tell you
|
||||
* whether the thing looks right. The map shipped twice on that basis and came
|
||||
* back wrong twice: once invisible behind a crash, once with its routes buried
|
||||
* under 966 pins.
|
||||
*
|
||||
* So this bundles the actual `GroupMap` — not a copy of it, not a sketch —
|
||||
* against invented stops, and writes one HTML file to open. What you see is
|
||||
* what the console draws.
|
||||
*
|
||||
* ── Why the mock data lives here and not in `src` ───────────────────────────
|
||||
*
|
||||
* `npm run verify:live` asserts there is no `src/demo`, and it is right to:
|
||||
* a fixture layer inside the app is how a screen ends up quietly rendering
|
||||
* invented numbers in production. This is a build tool. It imports from `src`
|
||||
* and nothing in `src` imports it, so the app has no path to this data.
|
||||
*
|
||||
* node scripts/mapPreview.mjs → writes scripts/.preview/map.html
|
||||
*/
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const out = join(here, '.preview');
|
||||
|
||||
/* ── The mock day ─────────────────────────────────────────────────────────
|
||||
Shaped like the real thing rather than like a neat demo: one shop, two
|
||||
riders working outward in a loop, three orders stacked on one address, and
|
||||
one rider whose last reported position is nowhere near their last drop. Each
|
||||
of those is something the live data does and each has broken this map once. */
|
||||
const SHOP = { lat: 11.0168, lng: 76.9558 };
|
||||
|
||||
const ROUND_A = [
|
||||
[11.0245, 76.9601],
|
||||
[11.0298, 76.9662],
|
||||
[11.0331, 76.9754],
|
||||
[11.0288, 76.9823],
|
||||
[11.0201, 76.9788],
|
||||
// Three orders at one address — the repeat customer that stacked 379 pins.
|
||||
[11.0154, 76.9702],
|
||||
[11.0154, 76.9702],
|
||||
[11.0154, 76.9702],
|
||||
];
|
||||
|
||||
const ROUND_B = [
|
||||
[11.0102, 76.9481],
|
||||
[11.0044, 76.9412],
|
||||
[10.9981, 76.9377],
|
||||
[10.9932, 76.9455],
|
||||
[11.0011, 76.9521],
|
||||
];
|
||||
|
||||
function stopsFor(rider, userid, points, from) {
|
||||
return points.map((point, index) => ({
|
||||
kind: 'delivery',
|
||||
row: {
|
||||
deliveryid: userid * 100 + index,
|
||||
orderid: `916-${userid}${String(index + 1).padStart(2, '0')}`,
|
||||
userid,
|
||||
ridername: rider,
|
||||
// A couple left open, so the status colours are visible on the pins.
|
||||
orderstatus: index === points.length - 1 ? 'active' : 'delivered',
|
||||
assigntime: '2026-08-25 09:00:00',
|
||||
deliverytime: `2026-08-25 ${String(from + Math.floor(index / 2)).padStart(2, '0')}:${String((index * 17) % 60).padStart(2, '0')}:00`,
|
||||
pickuplat: String(SHOP.lat),
|
||||
pickuplon: String(SHOP.lng),
|
||||
droplat: String(point[0]),
|
||||
droplon: String(point[1]),
|
||||
// Only the last stop carries a rider fix, which is the shape the live
|
||||
// rows have — a position arrives when a job moves, not per stop.
|
||||
...(index === points.length - 1
|
||||
? { riderslat: String(point[0] + 0.004), riderslon: String(point[1] - 0.003) }
|
||||
: {}),
|
||||
deliveryamt: 30 + index * 5,
|
||||
deliverycustomer: `${rider}'s customer ${index + 1}`,
|
||||
deliveryaddress: `Stop ${index + 1}`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const STOPS = [
|
||||
...stopsFor('Varun', 897, ROUND_A, 10),
|
||||
...stopsFor('Murali', 1111, ROUND_B, 11),
|
||||
];
|
||||
|
||||
const entry = join(out, 'entry.jsx');
|
||||
mkdirSync(out, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
entry,
|
||||
`import { createRoot } from 'react-dom/client';
|
||||
import { GroupMap } from '../../src/features/store-admin/GroupMap';
|
||||
const STOPS = ${JSON.stringify(STOPS)};
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<div style={{ padding: 16, maxWidth: 1100, margin: '0 auto' }}>
|
||||
<h1 style={{ font: '600 18px system-ui', margin: '0 0 4px' }}>Dispatch map — mock day</h1>
|
||||
<p style={{ font: '13px system-ui', color: '#5b6472', margin: '0 0 16px' }}>
|
||||
Two riders, one shop, ${STOPS.length} stops. Three of Varun's orders are at one address.
|
||||
This is the real GroupMap component with invented stops.
|
||||
</p>
|
||||
<GroupMap stops={STOPS} groupName="the mock day" />
|
||||
</div>,
|
||||
);
|
||||
`,
|
||||
);
|
||||
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
outfile: join(out, 'map.js'),
|
||||
jsx: 'automatic',
|
||||
format: 'iife',
|
||||
platform: 'browser',
|
||||
// Leaflet's stylesheet references sprite PNGs for controls we do not use;
|
||||
// inlined as data URIs so the page is a single self-contained file.
|
||||
loader: { '.css': 'css', '.png': 'dataurl', '.svg': 'dataurl' },
|
||||
// The real thing, minus the parts a static page has no business having.
|
||||
// `import.meta.env` is Vite's and does not exist here; the modules that read
|
||||
// it already optional-chain, so an empty object is enough.
|
||||
define: { 'import.meta.env': 'undefined', 'process.env.NODE_ENV': '"production"' },
|
||||
logLevel: 'warning',
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(out, 'map.html'),
|
||||
`<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dispatch map preview</title>
|
||||
<link rel="stylesheet" href="./map.css">
|
||||
<style>
|
||||
/* The console's own tokens, so the map is styled as it is in the app. */
|
||||
:root {
|
||||
--color-brand: #662582; --color-brand-strong: #531b6e; --color-brand-tint: #f4eef8;
|
||||
--color-ink-1: #1e2530; --color-ink-2: #414a58; --color-ink-3: #5b6472; --color-ink-4: #97a1b0;
|
||||
--color-surface: #fff; --color-surface-subtle: #fafbfc; --color-surface-sunken: #f2f4f7;
|
||||
--color-border: #e3e7ec;
|
||||
}
|
||||
body { margin: 0; background: #fff; font-family: system-ui, sans-serif; }
|
||||
.pva-note { display:flex; gap:7px; align-items:flex-start; padding:8px 10px; border-radius:7px;
|
||||
background: var(--color-surface-subtle); font-size:11.5px; line-height:1.5; color: var(--color-ink-3); }
|
||||
.map-key { display:inline-flex; gap:5px; align-items:center; font-size:11px; color:var(--color-ink-4); }
|
||||
.map-key i { width:9px; height:9px; margin-left:6px; border-radius:50%; background:var(--color-ink-4); }
|
||||
.map-key i:first-child { margin-left:0 }
|
||||
.map-key i[data-key='shop'] { border-radius:2px; background:var(--color-brand) }
|
||||
.map-key i[data-key='drop'] { background:#10b981 }
|
||||
.map-key i[data-key='rider'] { background:transparent; border:2px solid var(--color-ink-3) }
|
||||
.rider-chip { display:inline-flex; gap:6px; align-items:center; padding:4px 10px; border:1px solid var(--color-border);
|
||||
border-radius:999px; background:#fff; font:inherit; font-size:12px; color:var(--color-ink-2); cursor:pointer }
|
||||
.rider-chip[data-active='true'] { border-color:var(--color-brand); background:var(--color-brand-tint); color:var(--color-ink-1) }
|
||||
.rider-chip i { width:8px; height:8px; border-radius:50% }
|
||||
</style>
|
||||
<div id="root"></div>
|
||||
<script src="./map.js"></script>
|
||||
`,
|
||||
);
|
||||
|
||||
console.log('preview written to', join(out, 'map.html'));
|
||||
console.log('open it in a browser to see the real map with mock stops');
|
||||
211
scripts/refileCategories.ts
Normal file
211
scripts/refileCategories.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Puts a tenant's existing products into the aisles the customer app displays.
|
||||
*
|
||||
* Everything imported before this carries `subcategoryid: 0`, which the app
|
||||
* renders as one heading called "Uncategorized" holding the entire shop —
|
||||
* measured on live tenant 1135/1166 — while the catalogue has known all along
|
||||
* that an Aachi masala is Spices & Masalas. This reads that answer back, folds
|
||||
* it into one of the app's ten aisles (`appAisle.ts`) and writes it.
|
||||
*
|
||||
* `categoryid` is deliberately NOT changed. `getproductsbysubcategory` filters
|
||||
* on it with the 2 the app sends, so a per-product categoryid does not label a
|
||||
* product, it removes it from the app entirely.
|
||||
*
|
||||
* npx tsx scripts/refileCategories.ts 1147 # dry run, writes nothing
|
||||
* npx tsx scripts/refileCategories.ts 1147 --apply # writes
|
||||
*
|
||||
* ── How a product is matched to its catalogue row ───────────────────────────
|
||||
*
|
||||
* On `brand` + `productsku`, never on `catalogueid`. The catalogue renumbers
|
||||
* its ids on every re-scrape — 11 of 19 links were already broken when that was
|
||||
* last measured — so a product's stored `catalogueid` points at whatever
|
||||
* happens to sit at that number today, which may be a different product.
|
||||
*
|
||||
* ── What it does when there is no catalogue row ─────────────────────────────
|
||||
*
|
||||
* Falls back to the same deterministic ladder the import uses, so a product
|
||||
* typed in by hand is filed too rather than left behind. The report says which
|
||||
* source decided each one, because "the catalogue says so" and "we guessed from
|
||||
* the name" are different levels of confidence and an operator reviewing 300
|
||||
* rows deserves to know which is which.
|
||||
*/
|
||||
|
||||
import { catalogueApi } from '../src/api/catalogue';
|
||||
import { productsApi } from '../src/api/products';
|
||||
import {
|
||||
categoryForCatalogueProduct,
|
||||
UNKNOWN_CATEGORY,
|
||||
} from '../src/features/store-admin/productCategory';
|
||||
import { aisleForCategory, aisleIdsFrom } from '../src/features/store-admin/appAisle';
|
||||
import { APP_BROWSE_CATEGORY } from '../src/features/catalogue/tenantCategories';
|
||||
import type { CatalogueProduct, Product } from '../src/api/types';
|
||||
|
||||
const tenantid = Number(process.argv[2]);
|
||||
const isApply = process.argv.includes('--apply');
|
||||
|
||||
if (!tenantid) {
|
||||
console.error('Usage: npx tsx scripts/refileCategories.ts <tenantid> [--apply]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
type Source = 'catalogue' | 'ladder';
|
||||
|
||||
interface Plan {
|
||||
product: Product;
|
||||
/** The subcategory the product sits in today — 0 for everything, so far. */
|
||||
from: number;
|
||||
/** One of the catalogue's 31, for the report. */
|
||||
category: string;
|
||||
/** One of the app's ten aisles, or null when the category folds to none. */
|
||||
aisle: string | null;
|
||||
source: Source;
|
||||
}
|
||||
|
||||
/** Every catalogue row for one brand, keyed by SKU. One request per brand. */
|
||||
async function catalogueByBrand(brand: string): Promise<Map<string, CatalogueProduct>> {
|
||||
const out = new Map<string, CatalogueProduct>();
|
||||
for (let page = 0; page < 20; page += 1) {
|
||||
const rows = await catalogueApi.products({ brand, pageno: page, pagesize: 500 });
|
||||
for (const row of rows) {
|
||||
if (row.product_sku) out.set(row.product_sku.trim().toLowerCase(), row);
|
||||
}
|
||||
if (rows.length < 500) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const products = await productsApi.locationProducts({ tenantid, locationid: 0, pagesize: 2000 });
|
||||
console.log(`${products.length} products for tenant ${tenantid}\n`);
|
||||
|
||||
// One catalogue read per distinct brand, not one per product.
|
||||
const brands = [...new Set(products.map((p) => (p.productbrand ?? '').trim()).filter(Boolean))];
|
||||
const catalogue = new Map<string, Map<string, CatalogueProduct>>();
|
||||
for (const brand of brands) {
|
||||
try {
|
||||
catalogue.set(brand.toLowerCase(), await catalogueByBrand(brand));
|
||||
} catch {
|
||||
catalogue.set(brand.toLowerCase(), new Map());
|
||||
}
|
||||
}
|
||||
|
||||
const plans: Plan[] = [];
|
||||
for (const product of products) {
|
||||
const brand = (product.productbrand ?? '').trim().toLowerCase();
|
||||
const sku = (product.productsku ?? '').trim().toLowerCase();
|
||||
const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined;
|
||||
|
||||
/*
|
||||
The catalogue's answer only when it is one of the 31.
|
||||
|
||||
It carries names the platform does not have — "Food - Mixes", "Pickles &
|
||||
Chutneys", "Dairy - Desserts" on about a third of the rows sampled — and
|
||||
taking those verbatim would file a shop's products under aisles the app
|
||||
cannot browse and no other shop shares. The same gate the import uses.
|
||||
*/
|
||||
const verdict = categoryForCatalogueProduct({
|
||||
catalogueCategory: row?.category ?? '',
|
||||
title: product.productname ?? '',
|
||||
description: product.productdesc ?? '',
|
||||
packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '),
|
||||
});
|
||||
|
||||
plans.push({
|
||||
product,
|
||||
from: product.subcategoryid ?? 0,
|
||||
category: verdict.category,
|
||||
aisle: aisleForCategory(verdict.category),
|
||||
source: verdict.rule === 'catalogue' ? 'catalogue' : 'ladder',
|
||||
});
|
||||
}
|
||||
|
||||
// The aisle ids, by name, from the platform's own list — see `appAisle.ts`
|
||||
// for why they are matched on the name and not remembered as numbers.
|
||||
const aisleIds = aisleIdsFrom(
|
||||
await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY).catch(() => undefined),
|
||||
);
|
||||
|
||||
const byAisle: Record<string, number> = {};
|
||||
let unchanged = 0;
|
||||
const writes: Plan[] = [];
|
||||
const orphans: Plan[] = [];
|
||||
|
||||
for (const plan of plans) {
|
||||
if (!plan.aisle) {
|
||||
// Only worth reporting if the product has no aisle ALREADY. Several were
|
||||
// filed by hand long before any of this, and listing those as "would stay
|
||||
// under Uncategorized" says the opposite of what is true.
|
||||
if (plan.from === 0) orphans.push(plan);
|
||||
else unchanged += 1;
|
||||
continue;
|
||||
}
|
||||
const to = aisleIds.get(plan.aisle.toLowerCase()) ?? 0;
|
||||
if (to === plan.from) {
|
||||
unchanged += 1;
|
||||
continue;
|
||||
}
|
||||
writes.push(plan);
|
||||
const key = `${plan.aisle} ← ${plan.category} (${plan.source})`;
|
||||
byAisle[key] = (byAisle[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
console.log(`${writes.length} would be re-filed, ${unchanged} already in the right aisle
|
||||
`);
|
||||
Object.entries(byAisle)
|
||||
.sort((x, y) => y[1] - x[1])
|
||||
.forEach(([name, count]) => console.log(` ${String(count).padStart(4)} ${name}`));
|
||||
|
||||
if (orphans.length > 0) {
|
||||
console.log(
|
||||
`
|
||||
${orphans.length} have no aisle and would stay under the app's "Uncategorized":`,
|
||||
);
|
||||
orphans
|
||||
.slice(0, 10)
|
||||
.forEach((p) =>
|
||||
console.log(
|
||||
` ${(p.product.productname ?? '').slice(0, 44).padEnd(46)} ${p.category}`,
|
||||
),
|
||||
);
|
||||
const unknown = orphans.filter((p) => p.category === UNKNOWN_CATEGORY).length;
|
||||
if (unknown > 0) console.log(` (${unknown} of them could not be identified at all)`);
|
||||
}
|
||||
|
||||
console.log('\nA sample of what changes:');
|
||||
writes.slice(0, 12).forEach((p) => {
|
||||
console.log(
|
||||
` ${(p.product.productname ?? '').slice(0, 40).padEnd(42)} ${p.from} → ${p.aisle} (${p.source})`,
|
||||
);
|
||||
});
|
||||
|
||||
if (!isApply) {
|
||||
console.log('\nDry run. Nothing was written. Re-run with --apply to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\nWriting…');
|
||||
/*
|
||||
One call for the whole tenant, not one request per product.
|
||||
|
||||
`recategorise` writes the category and subcategory columns only, and is scoped
|
||||
by tenantid on the server, so it cannot reach another merchant's rows and cannot overwrite a price the
|
||||
way a whole-row update would. `PUT /products/update` was the obvious candidate
|
||||
and is the wrong one: it updates `productlocations.status` and never touches
|
||||
the products table at all.
|
||||
*/
|
||||
const updates = writes
|
||||
.map((plan) => ({
|
||||
productid: plan.product.productid,
|
||||
// Unchanged, and that is the point: it is the app's filter, not a label.
|
||||
categoryid: APP_BROWSE_CATEGORY,
|
||||
subcategoryid: aisleIds.get((plan.aisle ?? '').toLowerCase()) ?? 0,
|
||||
}))
|
||||
.filter((row) => row.subcategoryid > 0);
|
||||
|
||||
const skipped = writes.length - updates.length;
|
||||
const result = await productsApi.recategorise(tenantid, updates);
|
||||
console.log(`re-filed ${result?.moved ?? 0} of ${updates.length} sent`);
|
||||
if (skipped > 0) console.log(`${skipped} skipped — no id could be resolved for their aisle.`);
|
||||
}
|
||||
|
||||
void main();
|
||||
Reference in New Issue
Block a user