initial commit

This commit is contained in:
2026-09-28 17:24:00 +05:30
commit 9706fcc520
213 changed files with 53142 additions and 0 deletions

262
scripts/appfix.mjs Normal file
View 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
View 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
View 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();
}
}

View 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
View 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
View 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
View 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
View 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
View 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();