env edited
This commit is contained in:
150
scripts/db.mjs
150
scripts/db.mjs
@@ -32,7 +32,53 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ENDPOINT = process.env.HASURA_URL ?? 'https://api.workolik.com/v1/graphql';
|
||||
/**
|
||||
* Where Hasura actually lives, discovered rather than assumed.
|
||||
*
|
||||
* The first version of this hardcoded `/v1/graphql` at the host root and got a
|
||||
* 404. The old console's proxy is the clue it should have read: it rewrites
|
||||
* `/hasura` to `/api/rest/`, which means Hasura is mounted under `/api`, not at
|
||||
* the root. Rather than swap one guess for another, this tries the candidates
|
||||
* and uses whichever answers.
|
||||
*
|
||||
* Override with HASURA_URL if it moves again — pass the full GraphQL URL.
|
||||
*/
|
||||
const ENDPOINT_CANDIDATES = process.env.HASURA_URL
|
||||
? [process.env.HASURA_URL]
|
||||
: [
|
||||
'https://api.workolik.com/api/v1/graphql',
|
||||
'https://api.workolik.com/v1/graphql',
|
||||
'https://api.workolik.com/hasura/v1/graphql',
|
||||
];
|
||||
|
||||
let ENDPOINT = ENDPOINT_CANDIDATES[0];
|
||||
|
||||
/** Finds the first candidate that answers a trivial query. */
|
||||
async function resolveEndpoint() {
|
||||
for (const candidate of ENDPOINT_CANDIDATES) {
|
||||
try {
|
||||
const response = await fetch(candidate, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
|
||||
body: JSON.stringify({ query: '{ __typename }' }),
|
||||
});
|
||||
if (!response.ok) continue;
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (payload && !payload.errors) {
|
||||
ENDPOINT = candidate;
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// Next candidate.
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
'Could not find the Hasura GraphQL endpoint. Tried:\n' +
|
||||
ENDPOINT_CANDIDATES.map((c) => ` ${c}`).join('\n') +
|
||||
'\nSet HASURA_URL to the full GraphQL URL and run again.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Where the old console keeps its gitignored secret, relative to this repo. */
|
||||
const ENV_CANDIDATES = [
|
||||
@@ -223,6 +269,103 @@ async function sql(statement) {
|
||||
console.log(`\n${Math.max(0, rows.length - 1)} rows`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the app shows fewer products than the console does.
|
||||
*
|
||||
* node scripts/db.mjs appgap <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);
|
||||
@@ -232,6 +375,7 @@ const COMMANDS = {
|
||||
user: () => user(rest[0]),
|
||||
setpw: () => setpw(rest[0], rest[1]),
|
||||
sql: () => sql(rest.join(' ')),
|
||||
appgap: () => appgap(rest[0], rest[1]),
|
||||
};
|
||||
|
||||
if (!command || !COMMANDS[command]) {
|
||||
@@ -243,9 +387,13 @@ if (!command || !COMMANDS[command]) {
|
||||
' user <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]();
|
||||
|
||||
Reference in New Issue
Block a user