env edited
This commit is contained in:
13
.env
Normal file
13
.env
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# The Fiesta API host. Read by Vite at build time and compiled into the bundle.
|
||||||
|
#
|
||||||
|
# This is the single source of truth for where the console talks to the backend,
|
||||||
|
# and it is not a secret — it is the same public host the customer app calls.
|
||||||
|
#
|
||||||
|
# Committed on purpose: a deployed build has to carry it, and a value that lives
|
||||||
|
# only on one developer's machine is one the build server does not have. Vite
|
||||||
|
# only exposes `VITE_`-prefixed names to client code, so nothing else in here
|
||||||
|
# would reach the browser.
|
||||||
|
#
|
||||||
|
# `.env.local` overrides this and is gitignored — that is the file to use for a
|
||||||
|
# staging backend, or `/fiesta` to route through the dev proxy instead.
|
||||||
|
VITE_API_BASE="https://fiesta.nearle.app"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"test": "tsx --test \"src/**/*.test.ts\"",
|
"test": "tsx --test \"src/**/*.test.ts\"",
|
||||||
"contract": "node scripts/contract.mjs",
|
"contract": "node scripts/contract.mjs",
|
||||||
"db": "node scripts/db.mjs",
|
"db": "node scripts/db.mjs",
|
||||||
|
"appgap": "node scripts/appgap.mjs",
|
||||||
"verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\""
|
"verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
187
scripts/appgap.mjs
Normal file
187
scripts/appgap.mjs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
const realSubs = new Map(
|
||||||
|
(Array.isArray(subcategories) ? subcategories : []).map((row) => [
|
||||||
|
row.subcategoryid,
|
||||||
|
row.subcategoryname,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inApp = new Set();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const product of group.products ?? []) inApp.add(product.productid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = new Map();
|
||||||
|
const examples = new Map();
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
let reason;
|
||||||
|
if (inApp.has(product.productid)) {
|
||||||
|
reason = 'OK — the app shows this';
|
||||||
|
} else if (product.categoryid !== 2) {
|
||||||
|
reason = `A — categoryid is ${product.categoryid}, the app only asks for 2`;
|
||||||
|
} else if (!listed.has(product.productid)) {
|
||||||
|
reason = 'B — not listed at this outlet (no productlocations row)';
|
||||||
|
} else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) {
|
||||||
|
reason = `C — subcategoryid ${product.subcategoryid} is not a subcategory of 2, so it is dropped`;
|
||||||
|
} else {
|
||||||
|
// Everything checks out and it still is not there. Worth its own bucket
|
||||||
|
// rather than being folded into one of the above: a wrong guess here would
|
||||||
|
// send someone fixing data that is already correct.
|
||||||
|
reason = '? — passes all three checks but the app still does not return it';
|
||||||
|
}
|
||||||
|
const key = reason.replace(/\d+/g, 'N');
|
||||||
|
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||||
|
if (!examples.has(key)) examples.set(key, { product, reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Tenant ${tenantid}, outlet ${locationid}`);
|
||||||
|
console.log(` ${products.length} products in the catalogue`);
|
||||||
|
console.log(` ${listed.size} listed at this outlet`);
|
||||||
|
console.log(` ${inApp.size} returned by the app's endpoint\n`);
|
||||||
|
|
||||||
|
for (const [key, count] of [...buckets.entries()].sort((a, b) => b[1] - a[1])) {
|
||||||
|
const { product, reason } = examples.get(key);
|
||||||
|
console.log(` ${String(count).padStart(5)} ${reason}`);
|
||||||
|
console.log(
|
||||||
|
` e.g. "${product.productname}" — id ${product.productid}, category ${product.categoryid}, subcategory ${product.subcategoryid}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none returned)'}`,
|
||||||
|
);
|
||||||
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 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. */
|
/** Where the old console keeps its gitignored secret, relative to this repo. */
|
||||||
const ENV_CANDIDATES = [
|
const ENV_CANDIDATES = [
|
||||||
@@ -223,6 +269,103 @@ async function sql(statement) {
|
|||||||
console.log(`\n${Math.max(0, rows.length - 1)} rows`);
|
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 ─────────────────────────────────────────────────────────────── */
|
/* ── Dispatch ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const [command, ...rest] = process.argv.slice(2);
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
@@ -232,6 +375,7 @@ const COMMANDS = {
|
|||||||
user: () => user(rest[0]),
|
user: () => user(rest[0]),
|
||||||
setpw: () => setpw(rest[0], rest[1]),
|
setpw: () => setpw(rest[0], rest[1]),
|
||||||
sql: () => sql(rest.join(' ')),
|
sql: () => sql(rest.join(' ')),
|
||||||
|
appgap: () => appgap(rest[0], rest[1]),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!command || !COMMANDS[command]) {
|
if (!command || !COMMANDS[command]) {
|
||||||
@@ -243,9 +387,13 @@ if (!command || !COMMANDS[command]) {
|
|||||||
' user <email> show an account (never prints the password)',
|
' user <email> show an account (never prints the password)',
|
||||||
' setpw <email> <password> set a password on an account that has none',
|
' setpw <email> <password> set a password on an account that has none',
|
||||||
' sql "<select ...>" read-only SQL',
|
' sql "<select ...>" read-only SQL',
|
||||||
|
' appgap <tenant> <outlet> why the app shows fewer products than the console',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
process.exit(command ? 1 : 0);
|
process.exit(command ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Locate Hasura before anything talks to it.
|
||||||
|
await resolveEndpoint();
|
||||||
|
|
||||||
await COMMANDS[command]();
|
await COMMANDS[command]();
|
||||||
|
|||||||
@@ -9,9 +9,23 @@
|
|||||||
import type { FiestaEnvelope } from './types';
|
import type { FiestaEnvelope } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In dev, Vite proxies `/fiesta` -> https://fiesta.nearle.app (see
|
* Where Fiesta is.
|
||||||
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
|
*
|
||||||
* surprises. In production the deployed host is set by VITE_API_BASE.
|
* Set in `.env` as `VITE_API_BASE`, so the host is declared in one place rather
|
||||||
|
* than inferred here — Vite compiles it into the bundle at build time and both
|
||||||
|
* `npm run dev` and a deployed build use the same value.
|
||||||
|
*
|
||||||
|
* This module briefly decided the host itself, switching on `import.meta.env.DEV`.
|
||||||
|
* Explicit configuration is better: a rule in code that says "development means
|
||||||
|
* this, production means that" is invisible from the outside, and someone
|
||||||
|
* reading `.env` to find the backend would have found nothing.
|
||||||
|
*
|
||||||
|
* The `'/fiesta'` fallback is for a build with no `.env` at all. It routes
|
||||||
|
* through the dev proxy (`vite.config.ts`) or nginx (`nginx.conf.template`),
|
||||||
|
* both of which forward to the same host — so a missing variable degrades to a
|
||||||
|
* working same-origin path rather than to a broken one.
|
||||||
|
*
|
||||||
|
* Override per machine with `.env.local`, which is gitignored.
|
||||||
*/
|
*/
|
||||||
export const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
export const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,25 @@ export interface LocationProductQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const productsApi = {
|
export const productsApi = {
|
||||||
/** A store's own catalogue — what is actually imported, with live stock. */
|
/**
|
||||||
|
* A store's own catalogue — what is actually imported, with live stock.
|
||||||
|
*
|
||||||
|
* `pageno` is 1-BASED on the backend: `GetLocationProducts` clamps anything
|
||||||
|
* below 1 up to 1 (`productRepository.go:453`). So page 0 and page 1 both
|
||||||
|
* return the first page, and a caller counting from zero fetches page one
|
||||||
|
* twice and never sees the last one. The `+ 1` here is what makes a 0-based
|
||||||
|
* caller correct rather than off by one.
|
||||||
|
*
|
||||||
|
* `pagesize` defaults to 200 rather than 50 because nothing in the console
|
||||||
|
* paginates this yet: both call sites ask for one page and render it, so a
|
||||||
|
* shop with 80 products was showing 50 and silently dropping the rest.
|
||||||
|
*/
|
||||||
locationProducts: (query: LocationProductQuery) =>
|
locationProducts: (query: LocationProductQuery) =>
|
||||||
api.list<Product>(`${WEB}/products/getlocationproducts`, {
|
api.list<Product>(`${WEB}/products/getlocationproducts`, {
|
||||||
tenantid: query.tenantid,
|
tenantid: query.tenantid,
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
pageno: query.pageno ?? 0,
|
pageno: (query.pageno ?? 0) + 1,
|
||||||
pagesize: query.pagesize ?? 50,
|
pagesize: query.pagesize ?? 200,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user