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

117
src/api/assistant.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Nearle Buddy.
*
* Two calls: is this switched on, and here is a question. The tenant is not one
* of them — the server reads it from the session token, which is the whole
* point. A request body that carried a tenant would be a request body somebody
* could edit.
*/
import { api, errorMessage, WEB } from './client';
/** One tool the assistant ran, so an answer can show its working. */
export interface AssistantStep {
tool: string;
/** `ok` or `refused`. Refusals are shown, not hidden — an answer that quietly
dropped one would look like Buddy chose not to look. */
outcome: string;
rows?: number;
detail?: string;
scope?: string;
}
/** One line on an approval card: what is about to change, in checkable detail. */
export interface ProposalDetail {
label: string;
value: string;
}
/**
* A change Buddy has resolved and is waiting on a person for.
*
* Nothing has happened when this arrives. The `card` is signed by the server
* and opaque here — the console sends it back unchanged, and anything the
* browser altered stops it verifying.
*/
export interface Proposal {
summary: string;
details?: ProposalDetail[];
warning?: string;
card: string;
}
export interface AssistantAnswer {
reply: string;
used?: AssistantStep[];
model?: string;
/** Console routes showing the rows the answer came from. */
sources?: string[];
/**
* True when the loop hit its own limits, or the model's reply was cut off
* mid-sentence. The answer is still worth showing — a partial answer beats a
* spinner — but it must not be presented as the whole story.
*/
incomplete?: boolean;
/** Set when a change is resolved and waiting. One card, never a list. */
awaiting?: Proposal;
}
/**
* Is the assistant switched on for this deployment?
*
* Asked once when the panel mounts, and the answer decides whether the composer
* is enabled. The panel has said "Not connected yet" since it was built; this is
* what finally answers that at runtime rather than at build time.
*
* Never throws. A console that cannot reach this endpoint should show a
* disabled field, not an error page — the panel is beside the work, not the
* work itself.
*/
export async function assistantAvailable(): Promise<boolean> {
try {
const status = await api.get<{ available?: boolean; reason?: string }>(
`${WEB}/assistant/status`,
);
// The reason goes to the browser console, never to the panel. It names
// environment variables — useful to whoever deployed this, meaningless and
// faintly alarming to a shopkeeper. "Not connected yet" stays the only
// thing on screen; this is how somebody finds out which variable is wrong
// without a redeploy to add logging.
if (status?.available !== true && status?.reason) {
console.warn(`[nearle] Buddy is off: ${status.reason}`);
}
return status?.available === true;
} catch (cause) {
// A failed check and a configured-off server both disable the composer, and
// both used to leave exactly the same trace: nothing. So "Not connected yet"
// was read as "the deployment has no model" when it could equally have been
// a 500, an expired session or a blocked request — three problems with three
// different fixes, and no way to tell them apart from the screen.
console.warn('[nearle] Buddy status check failed:', errorMessage(cause));
return false;
}
}
/**
* Ask a question.
*
* `agent` names which assistant answers — the console sends the one matching the
* page the panel sits beside. Phase 2 ships one, so an unknown name is refused
* rather than silently answered by the wrong agent.
*/
export async function askAssistant(agent: string, question: string): Promise<AssistantAnswer> {
return api.post<AssistantAnswer>(`${WEB}/assistant/ask`, { agent, question });
}
/**
* Perform a change the person has agreed to.
*
* A separate call with no question in it, because it is a different act: the
* card names the action and the session names the person, and no model is
* consulted. The server re-checks both against the live database before writing
* — so this can legitimately fail with "somebody already approved that", which
* is an answer rather than an error.
*/
export async function approveAssistant(agent: string, card: string): Promise<AssistantAnswer> {
return api.post<AssistantAnswer>(`${WEB}/assistant/approve`, { agent, card });
}

137
src/api/catalogue.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* The global FMCG catalogue — a separate pgvector database, bridged to tenant
* data by the composite key `(brand, catalogueid)`.
*
* A catalogue row's bare `id` is only unique WITHIN its own brand table:
* `brand_dabur.id = 1` and `brand_nestle.id = 1` are different products. Every
* call that references a catalogue product sends both.
*/
import { api, WEB } from './client';
import type { CatalogueBrand, CatalogueProduct, CatalogueRef } from './types';
export interface CatalogueQuery {
/** Omit to search every brand merged — that is the "show everything" entry point. */
brand?: string;
category?: string;
keyword?: string;
pageno?: number;
pagesize?: number;
}
export const catalogueApi = {
/**
* Browse the global catalogue. Called with no `brand` this returns the full
* merged, paginated list — the list is never gated behind a brand selector.
*/
products: (query: CatalogueQuery = {}) =>
api.list<CatalogueProduct>(`${WEB}/catalogue/getproducts`, {
brand: query.brand,
category: query.category,
keyword: query.keyword,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 48,
}),
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
brands: () => api.list<CatalogueBrand>(`${WEB}/catalogue/getbrands`),
/**
* Every `image_id` → catalogue row id for one brand, in as few calls as the
* page size allows.
*
* Built for reconciling an ingest manifest. Resolving those one at a time is a
* request per product — a 500-row sheet would open 500 connections from a
* shop's browser — while a brand is at most a few hundred rows and comes back
* in one or two pages.
*
* `pagesize` is deliberately large but bounded, and paging stops on a short
* page rather than trusting a total the list endpoint does not return.
*/
idsByImageId: async (brand: string, pageSize = 500): Promise<Map<string, number>> => {
const out = new Map<string, number>();
for (let page = 0; page < 20; page += 1) {
const rows = await catalogueApi.products({ brand, pageno: page, pagesize: pageSize });
for (const row of rows) {
if (row.image_id && typeof row.id === 'number') out.set(row.image_id, row.id);
}
if (rows.length < pageSize) break;
}
return out;
},
/** Requires a brand — the backend reads categories from one brand's table. */
categories: (brand: string) =>
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
/**
* One catalogue row in full — the fields the import leaves behind
* (highlights, nutrients, FSSAI, every image, provider list).
*
* Returns nothing when a re-scrape has retired the source row, which is
* common: the tenant's product is a snapshot and outlives its origin.
*/
product: (brand: string, sku: string) =>
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproduct`, { brand, sku }),
/**
* One catalogue row by the id the ingest pipeline treats as canonical.
*
* An ingest run reports what it wrote as a manifest of `image_id` values, and
* `importcatalogueproduct` addresses products by `catalogueid` — the row id.
* This is the only bridge between the two, and without it a manifest could
* only be matched on the product NAME, which the owning team warns silently
* creates duplicates rather than updating.
*
* Prefer `productsByBrand` below when resolving more than a handful: this is
* one request per product.
*/
productByImageId: (brand: string, imageId: string) =>
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproductbyimageid`, {
brand,
image_id: imageId,
}),
/**
* The `(brand, catalogueid)` pairs this tenant has already imported, for
* badging "Imported" in the browser. Called without `brand` because the list
* mixes brands.
*/
importedRefs: (tenantid: number) =>
api.list<CatalogueRef>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
};
/**
* Key for the imported-refs lookup.
*
* `image_id` when there is one, and the brand-qualified id only as a fallback.
* The order matters: the catalogue is rebuilt by scrape and renumbered every
* time, so a tick placed by `catalogueid` lands on whatever product now holds
* that number — or, far more often, on nothing. Eleven of the nineteen links on
* the platform were in that state on 2026-08-31, which showed rows a shop
* really held as NOT imported and invited someone to import them again.
*
* `image_id` is the key the catalogue itself deduplicates on and survives both
* a renumber and a rename.
*/
export function catalogueKey(ref: CatalogueRef | CatalogueProduct): string {
const imageId = 'catalogueid' in ref ? ref.imageid : ref.image_id;
if (imageId) return `img:${imageId}`;
return 'catalogueid' in ref ? `${ref.brand}:${ref.catalogueid}` : `${ref.brand}:${ref.id}`;
}
/**
* Every key one imported ref can be recognised by.
*
* A ref carries both halves during the changeover — the stable key it has just
* acquired, and the id it was imported under years of scrapes ago. Emitting
* both means a browse screen keeps matching products that have not been
* relinked yet, instead of showing a shop's own stock as missing until someone
* runs the repair.
*/
export function catalogueKeysOf(ref: CatalogueRef): string[] {
const keys: string[] = [];
if (ref.imageid) keys.push(`img:${ref.imageid}`);
if (ref.catalogueid) keys.push(`${ref.brand}:${ref.catalogueid}`);
return keys;
}

View File

@@ -0,0 +1,85 @@
/**
* Which key a catalogue product is recognised by.
*
* This decides whether the browse screen shows a product as already imported.
* Getting it wrong is not cosmetic: a shop's own stock shown as missing gets
* imported a second time, and the shop ends up with duplicates.
*
* The reason it changed: `catalogueid` is renumbered by every re-scrape.
* Pepsico's live ids run 3, 6, 9 … 27, 30 — there is no 19, 25 or 26 — so on
* 2026-08-31 eleven of the nineteen links on the platform pointed at rows that
* no longer existed. `image_id` is the key the catalogue itself deduplicates on
* and survives both a renumber and a rename.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { catalogueKey, catalogueKeysOf } from './catalogue';
test('a product with a stable key is identified by it, not by its id', () => {
assert.equal(
catalogueKey({ brand: 'pepsico', id: 27, image_id: 'cheetos_chips_2d6bf74f' } as never),
'img:cheetos_chips_2d6bf74f',
);
assert.equal(
catalogueKey({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' } as never),
'img:cheetos_chips_2d6bf74f',
);
});
/*
The two sides have to agree. A ref from Fiesta and a product from the catalogue
describe the same thing under different field names — `imageid` and `image_id` —
and if they produced different keys the tick would never appear at all.
*/
test('a ref and a catalogue row agree on the key', () => {
const fromFiesta = catalogueKey({
brand: 'pepsico',
catalogueid: 27,
imageid: 'cheetos_chips_2d6bf74f',
} as never);
const fromCatalogue = catalogueKey({
brand: 'pepsico',
id: 27,
image_id: 'cheetos_chips_2d6bf74f',
} as never);
assert.equal(fromFiesta, fromCatalogue);
});
// The fallback still has to work: products imported before the column existed
// carry only the id, and they are genuinely imported.
test('without a stable key the brand-qualified id is used', () => {
assert.equal(catalogueKey({ brand: 'dabur', catalogueid: 19 } as never), 'dabur:19');
assert.equal(catalogueKey({ brand: 'dabur', id: 19 } as never), 'dabur:19');
});
// Brand-qualified, never bare. Each brand is its own table with its own
// sequence, so dabur 19 and pepsico 19 both exist and a bare id would tick the
// wrong product.
test('the fallback key keeps the brand, because ids repeat across brands', () => {
assert.notEqual(
catalogueKey({ brand: 'dabur', catalogueid: 19 } as never),
catalogueKey({ brand: 'pepsico', catalogueid: 19 } as never),
);
});
/*
During the changeover a ref carries both. Emitting only the stable key would
make every not-yet-relinked product read as missing the moment this shipped —
turning a silent problem into a visible one on every shop at once.
*/
test('a ref is recognised by both keys while the changeover runs', () => {
assert.deepEqual(
catalogueKeysOf({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' }),
['img:cheetos_chips_2d6bf74f', 'pepsico:27'],
);
});
test('a ref with only an id still yields its one key', () => {
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 19 }), ['dabur:19']);
});
// A ref with neither yields nothing rather than a key like "undefined:0" that
// would collide with every other broken ref and tick unrelated products.
test('a ref with nothing to match on yields no keys at all', () => {
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 0 }), []);
});

305
src/api/client.ts Normal file
View File

@@ -0,0 +1,305 @@
/**
* The Fiesta HTTP client.
*
* Everything the console knows about talking to the backend lives here, so the
* day the backend starts issuing a session token, this is the only file that
* changes. Nothing else in the app calls `fetch`.
*/
import { authHeader, forgetSession, readSessionToken } from '@/auth/token';
import type { FiestaEnvelope } from './types';
/**
* A 401 on a call we authenticated means the session is over.
*
* Twelve hours after signing in, or the moment the signing key is rotated under
* an open tab, every request starts coming back 401. Without this the console
* keeps sending the dead token and each page renders its own error — which a
* shopkeeper reads as "my data has gone", not as "sign in again". The screen
* fills with failures and nothing tells them the one thing that would fix it.
*
* Only when a token was actually SENT. A 401 on an anonymous call is the
* server declining to serve a stranger, not a session ending — and the
* sign-in probe deliberately posts with no password to read a 401 back, so
* reacting to that one would clear the session at the login screen and make
* signing in impossible.
*
* `location.reload()` rather than a router push: the session is held in React
* state that this module cannot reach, and a reload is the one move guaranteed
* to land on the sign-in screen from anywhere in the app. It happens once,
* because the storage is cleared first — the reloaded app has no token, so the
* next 401 cannot loop.
*/
function endDeadSession(path: string): void {
if (!readSessionToken()) return;
forgetSession();
// eslint-disable-next-line no-console
console.warn(`[nearle] session rejected on ${path}; signing out`);
if (typeof window !== 'undefined') window.location.reload();
}
/**
* Where Fiesta is.
*
* 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 fallback is the REAL HOST, not the same-origin `/fiesta` prefix it used
* to be. That prefix looked like a safe degradation and was not: a platform
* that writes its own `.env` into the build context (Dokploy does) erases the
* committed `VITE_API_BASE`, and the bundle then aims every call at whatever
* domain serves the console — `https://app.nearledaily.com/fiesta/live/api/...`
* instead of Fiesta. It kept working only because nginx happens to proxy that
* prefix, which is what made the misconfiguration invisible.
*
* Defaulting to the host means a missing variable can no longer silently
* re-point the backend at the console's own domain. `Dockerfile` also passes
* `VITE_API_BASE` as a build argument, so the value survives an overwritten
* `.env`.
*
* A trailing slash is stripped: every path below starts with `/`, and
* `https://host//live/api/...` is a different URL to the upstream router.
*
* Override per machine with `.env.local`, which is gitignored — set it to
* `/fiesta` to route through the dev proxy or nginx instead.
*/
/**
* Optional-chained for the same reason `ingest.ts` is: `import.meta.env` is
* Vite's, and it is undefined anywhere Vite is not — the test runner included.
* Without the `?.` this line throws on import, so every test that so much as
* names a module reaching this one fails before it runs, with a TypeError
* pointing here rather than at the test. The value already has a fallback; this
* only stops the read itself from being fatal.
*/
const configuredBase = (import.meta.env?.['VITE_API_BASE'] ?? '').trim();
export const API_BASE = (configuredBase || 'https://fiesta.nearle.app').replace(
/\/+$/,
'',
);
/** Every console route lives under this prefix. */
export const WEB = '/live/api/v1/web';
/**
* The console's POS reads — counter sales and till presence.
*
* `/web/pos`, NOT `/pos`. Those are two different doors and the difference is
* deliberate on the backend's side (`posroutes.go`): everything under `/v1/pos`
* sits behind `middleware.PosAuth`, which verifies a TERMINAL's session token.
* The console has no such token and cannot obtain one — `/pos/login` refuses an
* account that is not a till account, which is the separation working as
* intended.
*
* That guard currently waves unauthenticated requests through, so calling the
* terminal group appeared to work. The routes file says what happens next in as
* many words: "the moment `POS_AUTH_REQUIRED=true` is set, every POS screen in
* the back office goes dark." The same five reads are registered again under
* `/v1/web/pos` for exactly this caller, and that is where they belong.
*/
export const POS = '/live/api/v1/web/pos';
/**
* The mobile surface, for the two endpoints the web group does not carry.
*
* Not a preference — `tenants/getstaffs` is registered on `/v1/mob/tenants`
* only (`tenantroutes.go:35`), so the web path 404s.
*/
export const MOB = '/live/api/v1/mob';
/**
* A failed call, carrying the backend's own message.
*
* Fiesta answers HTTP 200 with `status: false` in several places, so the HTTP
* status alone is not enough to tell success from failure — both are checked.
*/
export class FiestaError extends Error {
readonly code: number;
readonly endpoint: string;
constructor(message: string, code: number, endpoint: string) {
super(message);
this.name = 'FiestaError';
this.code = code;
this.endpoint = endpoint;
}
/**
* True when the backend rejected the call for want of a scoping id.
*
* The IDOR pass added controller-level guards: an unscoped list call 400s
* rather than returning every tenant's rows. That is a bug in the caller,
* not a server fault, and it should surface as one.
*/
get isMissingScope(): boolean {
return this.code === 400 && /required/i.test(this.message);
}
}
export type QueryValue = string | number | boolean | null | undefined;
/** Drops empty params rather than sending `?tenantid=` and getting a 400 back. */
function toQueryString(params: Record<string, QueryValue> | undefined): string {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : '';
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
params?: Record<string, QueryValue>;
body?: unknown;
signal?: AbortSignal;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', params, body, signal } = options;
// There is exactly one path out of this function and it goes to `fetch`.
//
// A fixture short-circuit used to sit here, gated on a sessionStorage flag.
// It is gone: every screen in every workspace now shows what the API
// returned or an error, and there is no longer a mode in which the console
// shows something else convincingly.
const url = `${API_BASE}${path}${toQueryString(params)}`;
const init: RequestInit = {
method,
// `authHeader()` is read per request, never captured: sign-in and sign-out
// both happen while the app is running, and a header bound once would keep
// authorising calls for whoever signed in first.
headers: { Accept: 'application/json', ...authHeader() },
signal: signal ?? null,
};
if (body !== undefined) {
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
let response: Response;
try {
response = await fetch(url, init);
} catch (cause) {
// A network failure and a 500 read very differently to a user; keep them
// distinguishable rather than collapsing both into "something went wrong".
throw new FiestaError(
cause instanceof DOMException && cause.name === 'AbortError'
? 'Request cancelled'
: 'Could not reach the server',
0,
path,
);
}
let envelope: FiestaEnvelope<T>;
try {
envelope = (await response.json()) as FiestaEnvelope<T>;
} catch {
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
}
if (response.status === 401) {
endDeadSession(path);
}
if (!response.ok || envelope.status === false) {
throw new FiestaError(
envelope.message ?? `Request failed (HTTP ${response.status})`,
envelope.code ?? response.status,
path,
);
}
// Most handlers put the payload in `details`, but a handful answer with
// `data` instead — `products/getallproducts` and `products/create` among the
// ones the console calls (`productController.go:400,206`). Reading only
// `details` handed those two callers `undefined` with no error anywhere.
return (envelope.details ?? envelope.data) as T;
}
/**
* The whole envelope, for the handful of callers that need `message` or
* `tenantform` on success — login being the one that matters.
*/
async function requestEnvelope<T>(
path: string,
options: RequestOptions = {},
): Promise<FiestaEnvelope<T>> {
const { method = 'GET', params, body } = options;
const url = `${API_BASE}${path}${toQueryString(params)}`;
const init: RequestInit = { method, headers: { Accept: 'application/json', ...authHeader() } };
if (body !== undefined) {
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
let response: Response;
try {
response = await fetch(url, init);
} catch {
throw new FiestaError('Could not reach the server', 0, path);
}
try {
return (await response.json()) as FiestaEnvelope<T>;
} catch {
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
}
}
export const api = {
get: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
request<T>(path, { method: 'GET', params, signal }),
/**
* A read that returns rows.
*
* Fiesta answers an empty result with `details: null` about as often as with
* `[]` — `Scan` into a nil slice marshals as null, and which one you get
* depends on the handler rather than on anything meaningful. A page that maps
* over the answer then dies on a white screen, and it dies for the most
* ordinary case there is: a tenant with no branches yet, a shop with no
* customers.
*
* So the coercion happens once, here, rather than as `?? []` on forty call
* sites where the one that gets forgotten is the one that breaks. A non-array
* answer is treated as empty rather than thrown, because the alternative is
* an error screen for what is usually "nothing yet".
*/
list: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
request<T[] | null>(path, { method: 'GET', params, signal }).then((rows) =>
Array.isArray(rows) ? rows : [],
),
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'POST', body, params }),
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'PUT', body, params }),
del: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'DELETE', body, params }),
envelope: requestEnvelope,
};
/** Normalises anything thrown into a message worth showing a person. */
export function errorMessage(error: unknown): string {
if (error instanceof FiestaError) return error.message;
if (error instanceof Error) return error.message;
return 'Something went wrong';
}

92
src/api/customers.ts Normal file
View File

@@ -0,0 +1,92 @@
import { api, WEB } from './client';
/**
* Customers, as one branch sees them.
*
* `gettenantcustomers` genuinely branches on `locationid`: with one it INNER
* JOINs `tenantcustomers` and returns only the people registered against that
* outlet; without one it returns the tenant's whole book. So this is one of the
* few reads where the branch scope is honoured server-side rather than by us.
*
* The pagination is the trap. The controller supplies NO defaults — a missing
* `pageno`/`pagesize` becomes `LIMIT 0 OFFSET 0`, which returns an empty list
* rather than an error, and reads on screen as "this shop has no customers".
* Both are therefore always sent from here, never left to the caller.
*/
export interface CustomerInfo {
customerid: number;
firstname?: string;
lastname?: string;
contactno?: string;
email?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
landmark?: string;
doorno?: string;
postcode?: string;
deliverylocationid?: number;
tenantlocationid?: number;
applocationid?: number;
/**
* Where the customer is, as text — the column type in `app_customers`.
*
* Sent by `gettenantcustomers` on 97% of rows (measured across 31 customers
* at 7 shops, 2026-09-15) and simply absent from this interface until now, so
* the one nearly-complete piece of geography the backend has about a shop's
* customers was invisible to every page.
*/
latitude?: string;
longitude?: string;
/**
* Empty on every customer row on the platform — 0 of 31.
*
* Kept declared because the column exists and a future write could fill it,
* but nothing should render an Active/Inactive state from it: a badge that
* reads the same on every row is decoration, and one that reads blank is
* worse.
*/
status?: string;
}
export interface CustomerQuery {
tenantid: number;
/** Omit for the tenant's whole book. */
locationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}
export const customersApi = {
list: (query: CustomerQuery) =>
api.list<CustomerInfo>(`${WEB}/customers/gettenantcustomers`, {
tenantid: query.tenantid,
locationid: query.locationid,
keyword: query.keyword || undefined,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 100,
}),
};
/** A display name that never renders as an empty string. */
export function customerName(customer: CustomerInfo): string {
const name = [customer.firstname, customer.lastname].filter(Boolean).join(' ').trim();
return name || customer.contactno || `Customer ${customer.customerid}`;
}
/**
* Where they are, in the shortest true form.
*
* Door numbers are dropped: "12B" tells a shopkeeper nothing, and the old
* console's fallback of "Coimbatore" for anyone without an address invented a
* locality for every record that had none.
*/
export function customerLocality(customer: CustomerInfo): string {
const parts = [customer.suburb, customer.city].filter(Boolean) as string[];
if (parts.length > 0) return parts.join(', ');
const address = (customer.address ?? '').split(',').map((part) => part.trim());
const meaningful = address.find((part) => part.length > 3 && !/^\d/.test(part));
return meaningful ?? '—';
}

490
src/api/deliveries.ts Normal file
View File

@@ -0,0 +1,490 @@
import { api, WEB } from './client';
import type { RiderInfo } from './types';
import type { DeliveryDraft } from '@/features/store-admin/assignDelivery';
/**
* Deliveries — creating them, moving them along, and finding a rider.
*
* Separate from `insights.ts`, which only READS deliveries. The split is the
* same one the backend makes: `getdeliveries` answers "what is out there", and
* these three change it.
*/
/**
* The rider has no device registered.
*
* Its own error type because the remedy differs from a transport failure: the
* rider must open the app and sign in, not retry. Collapsed into a generic
* "notification failed", an operator assumes the network is at fault and tries
* again forever.
*/
export class RiderNotReachableError extends Error {
constructor(message = 'This rider has no device registered, so they were not told.') {
super(message);
this.name = 'RiderNotReachableError';
}
}
/** What the rider is told. Kept together so the wording stays consistent. */
export const RIDER_MESSAGE = {
assigned: (count: number) =>
count === 1
? 'An order has been assigned to you. Kindly accept and process the delivery.'
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
} as const;
/**
* Which fleet to ask for. One of these, in this order of preference.
*
* `getriders` scopes by applocation, partner or tenant. It used to be called
* with the region ALONE, which asks "who is on duty in this city" — so a
* merchant's assign picker offered every on-duty rider in Coimbatore, including
* other merchants' own riders and every other partner's.
*
* Measured 2026-09-09: 118 riders across three regions, and 117 of them belong
* to a delivery partner — 75 to partner 44 alone. Exactly one rider on the
* platform is a merchant's own. So the region scope was not a harmless default;
* it was the only thing holding the picker together while the two real scopes
* went unused.
*/
export interface RiderQuery {
/** The merchant's own riders — hired by them, working their branches. */
tenantid?: number;
/** A delivery partner's riders. One partner supplies many merchants. */
partnerid?: number;
/**
* The delivery region — a CITY, and the fallback for neither of the above.
*
* Kept because a caller with no merchant in hand still has to ask something,
* not because it is the right scope for an assign picker.
*/
applocationid?: number;
}
export const deliveriesApi = {
/**
* Riders on duty right now, for one OWNER.
*
* "On duty" is the backend's word, not a filter added here: the query wants
* `app_userpools.onduty = 1` and a `riderlogs` row stamped today with
* `logstatus = 0`. So this list empties overnight and refills as riders clock
* on, and an empty answer means nobody has started their shift — not that
* the shop has no riders. The picker has to say which.
*
* ── Why this is scoped and used to not be ─────────────────────────────────
*
* It sent `applocationid` alone, which asks "who is on duty in this city" —
* so a merchant's assign picker listed every on-duty rider in Coimbatore,
* including other merchants' own riders and every partner's. Nobody hit it
* because there is one rider on the platform. `getriders` scopes by
* applocation, partner or tenant, in that order, so the caller names which
* fleet it means and the region is only a fallback for neither.
*/
riders: (query: RiderQuery) =>
api.list<RiderInfo>(`${WEB}/partners/getriders`, {
...(query.tenantid ? { tenantid: query.tenantid } : {}),
...(query.partnerid ? { partnerid: query.partnerid } : {}),
...(query.tenantid || query.partnerid ? {} : { applocationid: query.applocationid }),
}),
/**
* Hand orders to a rider.
*
* An array, always, because that is what the endpoint takes and because one
* call is one transaction: each row inserts a `deliveries` row, copies it to
* `deliveryqueues` for the rider's app, and moves the parent order's status.
* Verified with three orders in a single call — three deliveries, three
* queue rows, nothing duplicated.
*
* The response carries no ids, only a message, so callers refetch rather
* than patching a row in place.
*/
assign: (rows: DeliveryDraft[]) =>
api.post<unknown>(`${WEB}/deliveries/createdeliveries`, rows),
/**
* Tell a rider they have work.
*
* Deliberately NOT chained into `assign` — the deliveries are committed by
* the time this runs, so a failed push must not read as a failed assignment.
* Callers fire it afterwards and report the two outcomes separately: a rider
* who was never told has work sitting unseen, which the operator needs to
* know without being told the assignment failed.
*
* The backend holds the Firebase credentials; this only relays.
*/
notify: (token: string, body: string) => {
// Checked here rather than at the server: posting an empty token returns
// FCM's "exactly one of token, topic or condition must be specified",
// which reads as a server fault rather than as a rider who has never
// opened the app. Verified against the live endpoint.
if (!token.trim()) throw new RiderNotReachableError();
return api.post<unknown>(`${WEB}/utils/notifyuser`, {
token: token.trim(),
notification: { title: 'NearleXpress', body },
});
},
/**
* Move a delivery along its ladder, or hand it to a different rider.
*
* `deliveryid` is the only field the backend insists on — it finds the row
* with it and derives the parent order from that rather than trusting the
* caller's `orderheaderid`, which is why a partial payload is safe here.
*
* Note what the backend does NOT do: `picked` updates the delivery and stops
* there, leaving the order at its previous status. Only pending, delivered
* and cancelled are mirrored onto the order.
*/
update: (body: UpdateDelivery) =>
api.put<unknown>(`${WEB}/deliveries/updatedelivery`, body),
};
/**
* The delivery lifecycle, lowercase, as `deliveries.orderstatus` stores it.
*
* `skipped` is real and reachable — the rider got there and nobody was in —
* but it is written by the rider's app, not from here, so it is not offered.
*/
export const DELIVERY_STEPS = ['pending', 'accepted', 'arrived', 'picked', 'active', 'delivered'] as const;
export type DeliveryStep = (typeof DELIVERY_STEPS)[number];
export interface UpdateDelivery {
deliveryid: number;
orderstatus: string;
/** Sent when known so the backend does not have to look it up. */
orderheaderid?: number;
/** Set to move the job to a different rider. */
userid?: number;
assigntime?: string;
starttime?: string;
arrivaltime?: string;
pickuptime?: string;
deliverytime?: string;
canceltime?: string;
}
/* ── Riders as people, not as a fleet ────────────────────────────────────── */
/**
* One rider being hired.
*
* Flat, though it lands in three tables — `app_users` for the person,
* `ridersettings` for the vehicle and licence, `app_userpools` for their place
* in the availability pool. The caller should not have to know the table layout
* to hire somebody, and the backend writes all three in one transaction.
*
* `tenantid` is NOT here. It goes on the query string and the backend takes it
* from there, so a payload cannot put a rider on another merchant's books.
*/
export interface NewRider {
userid?: number;
firstname: string;
lastname?: string;
contactno: string;
email?: string;
password?: string;
/** The delivery region. Defaulted from the branch — see `RiderDrawer`. */
applocationid: number;
/**
* Whose rider this is — one of these, never both.
*
* `tenantid` is a merchant's own rider; `partnerid` is a delivery partner's,
* who serves several merchants and sits under no single one. The server
* refuses neither and refuses both, so the two can never be confused
* downstream in a directory or an assign picker.
*/
tenantid?: number;
partnerid?: number;
/** The branch an OWN rider works out of. Meaningless for a partner's. */
locationid?: number;
shiftid: number;
identificationno?: string;
vehiclename?: string;
vehicleno?: string;
licenseno?: string;
registrationno?: string;
status?: string;
}
/**
* One rider in the directory.
*
* `isonduty` is the field to read for "are they working right now" — `onduty`
* is the availability flag, which is 1 for anyone who may be given work at all.
* A rider hired this morning has `onduty: 1` and `isonduty: false` until they
* open the app and start a shift.
*/
export interface RiderRosterRow {
userid: number;
firstname?: string;
lastname?: string;
fullname?: string;
contactno?: string;
email?: string;
tenantid?: number;
/** The branch an own rider works out of, and its name. */
locationid?: number;
locationname?: string;
applocationid?: number;
applocation?: string;
partnerid?: number;
partnername?: string;
shiftid?: number;
shiftname?: string;
identificationno?: string;
vehiclename?: string;
vehicleno?: string;
licenseno?: string;
registrationno?: string;
/** May be given work at all. */
onduty?: number;
lastlogdate?: string;
/** On shift right now — a log dated today. */
isonduty?: boolean;
status?: string;
}
export interface Partner {
partnerid: number;
partnername?: string;
companyname?: string;
applocationid?: number;
primarycontact?: string;
primaryemail?: string;
contactno?: string;
registrationno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
status?: string;
}
/** One region a partner covers — a row of `partnerlocations`. */
export interface PartnerLocation {
partnerlocationid: number;
partnerid: number;
applocationid: number;
applocation?: string;
}
/** A delivery region. `applocationid=0` asks for all of them. */
export interface AppLocation {
applocationid: number;
locationname?: string;
}
/** Everything the console collects to onboard a delivery partner. */
export interface NewPartner {
partnerid?: number;
partnername: string;
companyname?: string;
registrationno?: string;
primarycontact: string;
primaryemail?: string;
contactno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: number;
status?: string;
/** The district they work — one, never a set. */
applocationid: number;
/**
* The district by NAME, for one Nearle has not opened yet.
*
* Sending it opens the district: the server writes the `app_location` and
* `app_locationconfig` rows every rider query joins through. Ignored when
* `applocationid` is set, which is the ordinary case.
*/
district?: string;
}
export interface RiderShift {
shiftid: number;
shiftname?: string;
starttime?: string;
endtime?: string;
shifthours?: number;
}
/**
* A new working window.
*
* Times go as `HH:MM`; the server normalises `9:00` and `09:00:00` to the same
* thing, because the dropdown labels a shift by concatenating the two columns
* and the rows inserted by hand over the years use every spelling.
*/
export interface NewRiderShift {
applocationid: number;
shiftname?: string;
starttime: string;
endtime: string;
basefare?: number;
additionalcharges?: number;
fuelcharge?: number;
}
export const ridersApi = {
/**
* The directory — everyone, working today or not.
*
* NOT `getriders`, which requires a clock-in dated today. That one answers
* "who can take this delivery now" and is right for the assign picker; used
* as a staff list it hides the rider you just created, which reads as a
* failed save.
*/
roster: (tenantid: number) =>
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { tenantid }),
/**
* Hire one for a MERCHANT. `tenantid` travels as a param — the backend takes
* the scope from there rather than trusting the body, so a store admin cannot
* put a rider on another merchant's books by editing a payload.
*/
create: (tenantid: number, rider: NewRider) =>
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }),
/**
* Hire one for a delivery PARTNER.
*
* Same endpoint, same rider — what differs is who they ride for. A partner
* has no console of its own, so their riders are added by the platform.
*/
createForPartner: (partnerid: number, rider: NewRider) =>
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { partnerid }),
/** A partner's riders, for the platform's directory. */
partnerRoster: (partnerid: number) =>
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { partnerid }),
update: (rider: NewRider & { userid: number }) =>
api.put<unknown>(`${WEB}/partners/updaterider`, rider),
/** Shifts to choose from. Scoped by region, and the param is required. */
shifts: (applocationid: number) =>
api.list<RiderShift>(`${WEB}/partners/getridershifts`, { applocationid }),
/**
* Open a shift window in a region.
*
* A rider cannot be hired without a shift, and this table could only be read
* until now — so a region that shipped with no shift rows was a region no
* rider could ever be added to, from anywhere in the product. The drawer
* showed "No shifts set up for this region" and that was the end of it.
*
* `shifthours` is deliberately not sent. The server works it out from the two
* times, because it feeds rider pay and is the one field a person gets wrong
* with nothing downstream to catch it.
*/
createShift: (shift: NewRiderShift) =>
api.post<RiderShift>(`${WEB}/partners/createridershift`, shift),
/** Delivery partners a rider can ride for. */
partners: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
};
/**
* Delivery partners — the companies that supply riders.
*
* A partner is onboarded by the platform and then ASSIGNED to merchants; a
* merchant never creates one. That split is why `assign` lives on the tenant
* API and not here, and why `partnerid` is kept out of the merchant-editable
* profile allowlist on the server.
*
* One partner routinely serves many merchants: partner 44 supplies 48 of them
* and partner 60 supplies 63, measured on 2026-09-09.
*/
export const partnersApi = {
/** Every partner in a region. `applocationid` 0 is not accepted here. */
list: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
/** One partner, by id. */
byId: (partnerid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { partnerid }),
create: (partner: NewPartner) =>
api.post<{ partnerid: number }>(`${WEB}/partners/createpartner`, partner),
/**
* Edit a partner. Regions are REPLACED when sent and left alone when not, so
* an edit that changes only a phone number cannot empty the list.
*/
update: (partner: NewPartner & { partnerid: number }) =>
api.put<unknown>(`${WEB}/partners/updatepartner`, partner),
/** The regions one partner covers. */
locations: (partnerid: number) =>
api.list<PartnerLocation>(`${WEB}/partners/getpartnerlocations`, { partnerid }),
/**
* Every GPS ping a partner's riders sent over a window.
*
* ── Scope, and why this is a platform endpoint ────────────────────────────
*
* It filters on `partnerid` or on the rider's `applocationid` — never on a
* tenant. A partner's riders serve every merchant that partner supplies, so
* there is no tenant this could be scoped to, and asking by region would hand
* one merchant every rider in the city. That is why the fleet view lives in
* the platform console and not in a shop's.
*
* ── What comes back, and what it is not ───────────────────────────────────
*
* One row per ping: rider, timestamp, latitude, longitude. Dense — 320,132
* rows across eight riders for August 2026 — so a month-wide window is a
* large response and callers ask for a day or two at a time.
*
* The coordinates are NOT a trail. Every row for a given rider carries the
* same pair: one rider's 2,404 pings on 14 August 2026 all read 11.052998,
* 76.929958, and the same holds on every day and region checked. The app
* stamps a location once and repeats it on each heartbeat, so distance, speed
* and "time moving" cannot be derived from this and anything of that shape
* would be invented. Rider positions that actually move are written on the
* `deliveries` rows; see `deliveryTrack`.
*
* The row also carries `login`, `logout`, `workhours`, `shorthours` and
* `breakhours`, and every one of them is empty or zero on every row measured.
* Nothing closes a shift. So the timestamps are what this endpoint is good
* for — who was online and for how long — and shifts are inferred from the
* gaps between pings; see `riderShifts`.
*/
riderLogs: (query: { partnerid?: number; applocationid?: number; fromdate: string; todate: string }) =>
api.list<RiderPingRow>(`${WEB}/partners/getriderlogs`, {
...(query.partnerid ? { partnerid: query.partnerid } : {}),
...(query.applocationid ? { applocationid: query.applocationid } : {}),
fromdate: query.fromdate,
todate: query.todate,
}),
};
/**
* One row of `getriderlogs`.
*
* The shift columns are typed because they are sent, and documented as empty
* because they are: nothing on the platform writes them. Reading `workhours`
* and believing it is the mistake this comment exists to prevent.
*/
export interface RiderPingRow {
logid?: number;
logdate: string;
userid: number;
username?: string;
partnerid?: number;
latitude?: string;
longitude?: string;
shiftid?: number;
shifthours?: number;
/** Always empty on production data. See `riderLogs`. */
login?: string;
/** Always empty on production data. See `riderLogs`. */
logout?: string;
/** Always 0 on production data. See `riderLogs`. */
workhours?: number;
shorthours?: number;
breakhours?: number;
logstatus?: number;
}

370
src/api/ingest.test.ts Normal file
View File

@@ -0,0 +1,370 @@
/**
* The review inbox, and the status that nearly slipped through as success.
*
* The fixture is the live response to an anonymous upload on 28 Aug 2026 —
* `status: "pending"`, every total zero, the file still queued.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
isAwaitingReview,
isDismissed,
isSettled,
pollDelayFor,
currentStage,
isStuckOnMissingRunner,
productsOf,
releasedRunId,
summarise,
type IngestBatch,
} from './ingest';
const held = {
batch_id: '2ee38d06b583454ea0278a7f6de2c87f',
status: 'pending',
detail: 'Waiting for review. Nothing runs until an admin starts it.',
submitted_by: 'anonymous',
files_total: 1,
files_done: 0,
files_failed: 0,
totals: {
rows_total: 0,
products_built: 0,
inserted: 0,
backfilled: 0,
skipped_existing: 0,
rejected: 0,
},
brands: [],
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const }],
} satisfies IngestBatch;
// The bug this guards: `isSettled` used to mean "not queued and not running",
// so `pending` counted as finished and the panel rendered a completed import of
// zero products for a batch that had not started.
test('a batch held for review is not treated as finished', () => {
assert.equal(isSettled(held), false, 'a held batch must not read as settled');
assert.equal(isAwaitingReview(held), true);
});
test('the summary says it is waiting, not that nothing imported', () => {
const line = summarise(held);
assert.match(line, /review/i);
assert.doesNotMatch(line, /0 added/, 'must not report an import that never ran');
});
test('a real result is still settled', () => {
for (const status of ['done', 'partial', 'failed', 'interrupted', 'cancelled'] as const) {
assert.equal(isSettled({ ...held, status }), true, `${status} should be settled`);
}
});
test('queued and running are still in flight', () => {
for (const status of ['queued', 'running'] as const) {
assert.equal(isSettled({ ...held, status }), false, `${status} should not be settled`);
}
});
// isSettled is written as a positive list precisely so a status nobody
// anticipated stalls a spinner rather than fabricating a completed import.
test('an unknown future status does not read as finished', () => {
const unknown = { ...held, status: 'quarantined' as unknown as IngestBatch['status'] };
assert.equal(isSettled(unknown), false);
});
/* ── The drop lifecycle ───────────────────────────────────────────────────── */
const released = {
...held,
batch_id: '9f088d949aa9',
status: 'pending' as const,
files: [{ index: 0, filename: 'qa.csv', status: 'released' as const, released_to: '8dcef8a2ad94' }],
} satisfies IngestBatch;
const dismissed = {
...held,
files: [{ index: 0, filename: 'qa.csv', status: 'dismissed' as const, released_to: null }],
} satisfies IngestBatch;
// A released drop is not still waiting — the run is one hop away, and treating
// it as held would leave the screen saying "queued for review" forever.
test('a released drop is no longer awaiting review', () => {
assert.equal(releasedRunId(released), '8dcef8a2ad94');
assert.equal(isAwaitingReview(released), false);
assert.equal(isAwaitingReview(held), true, 'an unreleased drop is still waiting');
});
// Declined is terminal. Polling on is waiting for something that cannot happen.
test('a dismissed drop is recognised and reported as declined', () => {
assert.equal(isDismissed(dismissed), true);
assert.equal(isDismissed(held), false);
assert.match(summarise(dismissed), /declined/i);
assert.doesNotMatch(summarise(dismissed), /0 added/);
});
test('the manifest is collected across files', () => {
const done = {
...held,
status: 'done' as const,
files: [
{
index: 0,
filename: 'a.csv',
status: 'done' as const,
result: {
products: [
{
image_id: 'amul_amul_butter_100g',
brand: 'amul',
product_name: 'Amul Butter 100g',
product_sku: 'ACME-BUT-100',
sku_source: 'sheet',
disposition: 'inserted' as const,
},
],
},
},
],
} satisfies IngestBatch;
const products = productsOf(done);
assert.equal(products.length, 1);
// image_id is the join key; matching on name creates duplicates instead of
// updating, which is why it is asserted rather than the name.
assert.equal(products[0]?.image_id, 'amul_amul_butter_100g');
assert.equal(products[0]?.disposition, 'inserted');
});
/* ── retired: the drop is spent, the answer is on the files ───────────────── */
// A drop released into a run reads `retired`, and the run is elsewhere. Calling
// it finished would report an import that is running right now as a completed
// import of zero products.
test('a retired drop that was released is not finished', () => {
const retired = {
...held,
status: 'retired' as const,
files: [
{ index: 0, filename: 'qa.csv', status: 'released' as const, released_to: '8dcef8a2ad94' },
],
} satisfies IngestBatch;
assert.equal(isSettled(retired), false, 'the run still has to be followed');
assert.equal(releasedRunId(retired), '8dcef8a2ad94');
});
// Retired with nothing to follow is genuinely over — otherwise the panel spins
// on a drop that no longer exists.
test('a retired drop with nowhere to follow is finished', () => {
const retired = {
...held,
status: 'retired' as const,
files: [{ index: 0, filename: 'qa.csv', status: 'dismissed' as const, released_to: null }],
} satisfies IngestBatch;
assert.equal(isSettled(retired), true);
});
/* ── Cross-drop contamination ─────────────────────────────────────────────── */
// An admin can assemble one run from several drops, so a run's manifest can
// carry other senders' products. Applying our sheet's price and opening stock
// to those would stock someone else's goods into our merchant's branch.
test('only our own file contributes products', () => {
const run = {
...held,
status: 'done' as const,
files: [
{
index: 0,
filename: 'ours.csv',
status: 'done' as const,
result: {
products: [
{ image_id: 'amul_a', brand: 'amul', product_name: 'Ours', disposition: 'inserted' as const },
],
},
},
{
index: 1,
filename: 'someone-elses.csv',
status: 'done' as const,
result: {
products: [
{ image_id: 'amul_b', brand: 'amul', product_name: 'Theirs', disposition: 'inserted' as const },
],
},
},
],
} satisfies IngestBatch;
const mine = productsOf(run, ['ours.csv']);
assert.equal(mine.length, 1);
assert.equal(mine[0]?.product_name, 'Ours');
// Unfiltered still returns everything — the filter is the caller's decision,
// and every caller that prices products must make it.
assert.equal(productsOf(run).length, 2);
});
/*
The stage timeline, and the runner that silently isn't there.
Both arrived with the ingest team's 31 Aug documentation update. The timeline is
what lets the console draw the real eleven stages instead of a file-count bar;
the runner is a trap, and the more important of the two.
*/
const runningFile = {
index: 0,
filename: 'catalog.csv',
status: 'running' as const,
stage_index: 6,
stage_name: 'Image Search & Contamination Filtering',
total_stages: 11,
rows_done: 120,
rows_total: 400,
stages: [
{
index: 1,
name: 'Brand Resolution & FSSAI Licence Mapping',
rows_done: 400,
rows_total: 400,
started_at: 1756612800.1,
finished_at: 1756612801.4,
},
{
index: 6,
name: 'Image Search & Contamination Filtering',
rows_done: 120,
rows_total: 400,
started_at: 1756612809.7,
finished_at: null,
},
],
};
// `finished_at: null` is the marker, not the last array entry and not
// `stage_index`. Reading the position any other way breaks the moment a stage
// completes out of order or the array carries a trailing finished entry.
test('the running stage is the one with no finish time', () => {
const stage = currentStage(runningFile);
assert.equal(stage?.index, 6);
assert.equal(stage?.rows_done, 120);
});
// A finished file keeps its history, which is the whole reason the timeline
// exists — the scalars only ever describe the present moment, and for a
// finished file that moment is over.
test('a finished file still reports its last stage', () => {
const done = {
...runningFile,
status: 'done' as const,
stages: runningFile.stages.map((s) => ({ ...s, finished_at: s.finished_at ?? 1756612900.0 })),
};
assert.equal(currentStage(done)?.index, 6);
});
// A service build that predates the timeline still has to render. The scalars
// are the fallback, not the source of truth.
test('a response without a timeline falls back to the scalars', () => {
const { stages: _stages, ...noTimeline } = runningFile;
const stage = currentStage(noTimeline);
assert.equal(stage?.index, 6);
assert.equal(stage?.name, 'Image Search & Contamination Filtering');
});
test('a file that has not started reports no stage at all', () => {
assert.equal(currentStage({ index: 0, filename: 'a.csv', status: 'queued' }), null);
});
/*
`runner: "dagster"` never runs in production — Dagster is a development tool,
absent from the deployed image — so the batch waits for a worker that will never
claim it. Every visible signal is identical to a batch merely waiting its turn,
which is exactly why it has to be named rather than rendered as progress.
*/
test('a batch staged for the absent orchestrator is called out', () => {
assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'dagster' }), true);
});
test('the in-process runner is not a stall', () => {
assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'inprocess' }), false);
});
// A batch that reached `running` plainly found an executor, whatever it was
// staged for. Warning then would contradict the progress on screen.
test('a batch already running is not stuck, whatever it was staged for', () => {
assert.equal(isStuckOnMissingRunner({ ...held, status: 'running', runner: 'dagster' }), false);
});
/*
A drop is not a run, and the difference is easy to lose.
`released_to` lives on the DROP's files. Once an admin releases it, following
that pointer lands on the run — and the run carries no `released_to` of its own,
because nothing released it. So a caller who resolves first and asks for the run
id second gets null, and the only pointer from the id they hold to the id with
the results is never recorded.
This cost a real bug in both directions: the Uploads page never saved a run id,
and the import panel keyed the shelving write on `batch.batch_id` — which by
then was the run — updating a receipt row that does not exist, silently.
*/
test('the run id is on the drop, and gone from the run it points to', () => {
const drop = {
...held,
batch_id: 'drop-1',
status: 'retired' as const,
files: [
{ index: 0, filename: 'catalog.csv', status: 'released' as const, released_to: 'run-1' },
],
};
assert.equal(releasedRunId(drop), 'run-1');
// The same question asked of the run answers null. Read the drop first.
const run = {
...held,
batch_id: 'run-1',
status: 'done' as const,
files: [{ index: 0, filename: 'catalog.csv', status: 'done' as const }],
};
assert.equal(releasedRunId(run), null);
// And the two ids differ, which is exactly why a receipt keyed on the drop
// cannot be written using the run's.
assert.notEqual(drop.batch_id, run.batch_id);
});
/* ── How often to look, and when to stop looking ──────────────────────────── */
/*
The console used to stop polling the moment a drop went to review, on the
reasoning that waiting for an admin is not progress. It is not — but the release
IS, and stopping there meant the panel said "waiting for review" until somebody
reloaded the page. A step-by-step panel that only advances on reload is the
thing the panel exists to replace.
*/
test('a review hold is polled slowly, not abandoned', () => {
assert.equal(isAwaitingReview(held), true);
assert.equal(pollDelayFor(held), 15000, 'a hold can last hours; 2s would be 1,800 reads an hour');
});
test('a running batch is polled at a pace a person can watch', () => {
const running = { ...held, status: 'running' } satisfies IngestBatch;
assert.equal(isAwaitingReview(running), false);
assert.equal(pollDelayFor(running), 2000);
});
// A released drop is no longer waiting on anybody, so it goes back to the fast
// cadence even though its own status still reads "pending".
test('a released drop is followed at the running pace', () => {
const released = {
...held,
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const, released_to: 'run-77' }],
} satisfies IngestBatch;
assert.equal(releasedRunId(released), 'run-77');
assert.equal(isAwaitingReview(released), false);
assert.equal(pollDelayFor(released), 2000);
});

819
src/api/ingest.ts Normal file
View File

@@ -0,0 +1,819 @@
/**
* The catalogue ingest service — `mcp.nearle.ai.in`.
*
* A spreadsheet goes up, an admin reviews it, and once released the eleven-stage
* pipeline writes the products into the global catalogue. From there Fiesta
* already sees them: `/web/catalogue/getbrands` and `/web/catalogue/getproducts`
* read the SAME database the pipeline writes to, so an upload appears in the
* console with nothing in between to build or synchronise.
*
* ── A drop is not a run ──────────────────────────────────────────────────────
*
* `POST /api/uploads/catalog` creates a DROP, and nothing runs on arrival. The
* files wait in an admin review inbox; only when someone selects them and
* presses Start does a RUN begin, under a different id. The drop id stays valid
* for the whole lifecycle and its per-file status is how you follow it:
*
* queued — still in the inbox, nobody has looked
* released — accepted; `released_to` is the run, and the results are there
* dismissed — declined; nothing further is coming
*
* `resolveBatch` below makes that hop automatically, so callers poll one id and
* get whichever record actually has the answer.
*
* ── No credential ────────────────────────────────────────────────────────────
*
* The drop endpoint takes none, and that is safe precisely because of the review
* gate: an unwanted drop costs disk until somebody declines it, never products
* in the live catalogue.
*
* So `INGEST_TOKEN` should be left EMPTY. nginx omits an empty header, and a
* WRONG key is a 401 rather than a downgrade to anonymous — verified against the
* live service. A stale token in the environment would therefore break every
* upload while looking like a service fault.
*
* Only the LIST read (`GET /api/uploads/catalog`) still wants a credential;
* reading one batch by its id does not, because the id is itself the proof of
* having sent it.
*/
/**
* Optional-chained because `import.meta.env` is Vite's, and it is undefined
* anywhere Vite is not — the `node --test` runner included. Without the `?.`
* this line throws on import, so every test that so much as names this module
* fails before it runs, with a TypeError that points here rather than at the
* test. Cheap insurance for a value that already has a fallback.
*/
const INGEST_BASE = import.meta.env?.['VITE_INGEST_BASE'] ?? '/ingest';
const ROOT = '/api/uploads/catalog';
/* ── Limits, mirroring the service's own ──────────────────────────────────── */
/**
* Checked here so a drop that cannot possibly be accepted is refused in the
* browser rather than uploaded over a shop's connection to earn a 413. The
* service remains the authority; this is politeness, not validation.
*/
export const MAX_FILES = 20;
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
export const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
/** Per file. A sheet over this is marked failed; the rest of the batch runs. */
export const MAX_ROWS = 2000;
/** Everything the service parses, from the documented format list. */
export const ACCEPTED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.tsv'];
/* ── Response types, from the owning team's documented output ─────────────── */
/**
* Seven states, not four.
*
* `partial` and `interrupted` are the two that matter and the two a client is
* most likely to collapse into something else. `interrupted` means a restart
* cut the batch short; it never auto-restarts and needs an admin to resume it,
* so reporting it as `failed` would send someone re-uploading a batch that is
* waiting to be continued.
*/
export type BatchStatus =
/**
* Accepted and staged, but NOTHING RUNS until an admin releases it.
*
* A review inbox now sits in front of the pipeline — the service answers
* `"Waiting for review. Nothing runs until an admin starts it."` — and this
* status was not in the contract we were given. It matters far more than an
* extra enum member: `isSettled` originally read "not queued and not
* running", so `pending` counted as FINISHED and the panel rendered a
* completed batch reporting nothing imported. An upload that had not yet
* begun would have been shown as a successful import of zero products.
*/
| 'pending'
/**
* Every file in this DROP has been released or dismissed — the drop is spent.
*
* Not an outcome of its own: the answer is on the files. A released file
* carries `released_to`, which is where the run actually is; a dismissed one
* carries nothing because nothing will come. Treating `retired` as finished
* would report a drop that was accepted and is running right now as a
* completed import of zero products.
*/
| 'retired'
| 'queued'
| 'running'
| 'done'
| 'partial'
| 'failed'
| 'interrupted'
| 'cancelled';
/**
* A file inside a drop.
*
* `released` and `dismissed` are the review inbox's two outcomes and neither is
* a result: released means an admin accepted it and the RUN is somewhere else —
* follow `released_to` — while dismissed means they declined it and nothing will
* ever come. Reading either as a finished import reports products that were
* never written.
*/
export type BatchFileStatus =
| 'queued'
| 'running'
| 'done'
| 'failed'
| 'released'
| 'dismissed';
/**
* One product the pipeline wrote, from the run's manifest.
*
* `image_id` is the join key and the only safe one. The owning team calls it
* "the primary key every other product is deduplicated on", and warns that a
* product name differing by one character is a different product — so matching
* a manifest on NAME silently creates duplicates instead of updating.
*
* `unchanged` rows are included on purpose: re-sending a sheet writes nothing,
* and omitting them would make a completely successful upload return an empty
* list that reads as total failure.
*/
export interface IngestProduct {
image_id: string;
brand: string;
product_name: string;
product_sku?: string;
/** `sheet` when the sheet supplied it, `Internal` when the pipeline minted one. */
sku_source?: string;
disposition: 'inserted' | 'backfilled' | 'unchanged';
}
/** What the pipeline made of one file, once it has finished. */
export interface BatchFileResult {
rows_total?: number;
/** Can exceed `rows_total`: "100g, 200g, 500g" in one cell is three products. */
products_built?: number;
inserted?: number;
/** Existing rows whose blank columns this upload filled in. */
backfilled?: number;
/** Already present and already complete — nothing to do. */
skipped_existing?: number;
rejected?: number;
/** Sheet header → the field it was read as. */
recognised_columns?: Record<string, string>;
/** Headers that matched nothing. Reported, never an error. */
unrecognised_columns?: string[];
/** Non-null means rows were built but never stored. */
storage_error?: string | null;
/**
* What the run actually wrote, product by product. Returned on the
* single-batch read only — the list endpoints omit it, because twenty runs of
* thousands of rows is not a list payload.
*/
products?: IngestProduct[];
/** True when the manifest was capped at 5,000 rows for this file. */
products_truncated?: boolean;
}
/**
* One stage a file has entered, from the run's own timeline.
*
* `finished_at: null` marks the stage running RIGHT NOW — that is how the
* current position is found, not by trusting `stage_index` alone. The array
* persists after the run ends, so a finished file can still show its whole
* history; the scalars on the file only ever describe the present moment, which
* is why they are not enough on their own.
*/
export interface BatchStage {
index: number;
name: string;
rows_done?: number;
rows_total?: number;
/** Epoch SECONDS as a float, like every other timestamp here. */
started_at?: number;
finished_at?: number | null;
}
export interface BatchFile {
index: number;
filename: string;
status: BatchFileStatus;
/** Present on a file the service refused to read, and the reason it gives. */
detail?: string | null;
/**
* The RUN this file became once an admin released it.
*
* Null while it waits and after it is dismissed. The drop id stays valid for
* the whole lifecycle — an earlier build deleted the drop on release and the
* poll started 404ing, which made running, declined and lost look identical
* from outside.
*/
released_to?: string | null;
size_bytes?: number;
rows_total?: number;
/** Progress through the eleven stages, while it runs. */
stage_index?: number;
stage_name?: string;
total_stages?: number;
rows_done?: number;
/** The stages this file has entered, oldest first. */
stages?: BatchStage[];
result?: BatchFileResult | null;
}
export interface BatchTotals {
rows_total: number;
products_built: number;
inserted: number;
backfilled: number;
skipped_existing: number;
rejected: number;
}
export interface IngestBatch {
batch_id: string;
status: BatchStatus;
detail: string | null;
submitted_by?: string;
/** Epoch SECONDS, not milliseconds — multiply before handing to `Date`. */
created_at?: number;
updated_at?: number;
files_total: number;
files_done: number;
files_failed: number;
current_file?: string | null;
use_llm?: boolean;
fetch_images?: boolean;
/**
* All eleven stage names, in order.
*
* Served rather than left for us to hardcode, deliberately — draw the
* pipeline from this and the console cannot drift out of step when a stage is
* added or renamed on their side.
*/
stage_names?: string[];
/**
* Who is executing the batch.
*
* `"dagster"` is a silent failure in production and has to be surfaced rather
* than rendered as progress. Dagster is a local development orchestrator — it
* is absent from the deployed image, which never copies `orchestration/` — so
* a batch staged for it is handed to nobody and parks at `queued` forever
* saying "Waiting for the Dagster orchestrator to pick this batch up". From
* outside that is indistinguishable from a hang, and the fix is not to wait:
* an admin resumes it onto the in-process worker.
*/
runner?: 'inprocess' | 'dagster' | string;
totals: BatchTotals;
/** The brands this batch touched — the way back into the catalogue view. */
brands: string[];
files: BatchFile[];
/** Present only on the POST response; the polling reads omit it. */
message?: string;
}
export class IngestError extends Error {
readonly status: number;
readonly body: string;
constructor(message: string, status: number, body = '') {
super(message);
this.name = 'IngestError';
this.status = status;
this.body = body;
}
}
/* ── Requests ─────────────────────────────────────────────────────────────── */
export interface SubmitOptions {
files: File[];
/**
* A label for the review inbox, so the admin can see who sent what.
*
* Free text, trimmed to 60 characters by the service, and defaulting to
* "anonymous" when omitted. It is worth sending: the drop endpoint takes no
* credential, so without this every submission in the inbox is indistinguishable
* and an admin approving one cannot tell whose it is.
*/
sender?: string;
signal?: AbortSignal;
}
/**
* Refuses a drop the service is certain to reject, and says which file is at
* fault rather than reporting the batch as generically too large.
*/
function guardFiles(files: File[]): void {
if (files.length === 0) {
throw new IngestError('Choose at least one file.', 400);
}
if (files.length > MAX_FILES) {
throw new IngestError(
`That is ${files.length} files. The service takes ${MAX_FILES} per upload — send them in smaller batches.`,
413,
);
}
for (const file of files) {
if (file.size === 0) {
throw new IngestError(`"${file.name}" is empty.`, 400);
}
if (file.size > MAX_FILE_BYTES) {
throw new IngestError(
`"${file.name}" is ${(file.size / 1024 / 1024).toFixed(1)} MB. The limit is 10 MB per file.`,
413,
);
}
/**
* The FILENAME picks the parser, not the bytes. A name the service does not
* recognise comes back as an unexplained parse failure, so it is named here
* instead.
*/
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (!file.name.includes('.') || !ACCEPTED_EXTENSIONS.includes(extension)) {
throw new IngestError(
`"${file.name}" is not a format the service reads. Accepted: ${ACCEPTED_EXTENSIONS.join(', ')}.`,
400,
);
}
}
const total = files.reduce((sum, file) => sum + file.size, 0);
if (total > MAX_TOTAL_BYTES) {
throw new IngestError(
`That is ${(total / 1024 / 1024).toFixed(1)} MB in total. The limit is 50 MB per upload.`,
413,
);
}
}
/**
* Submits the sheets. Answers 202 with a batch to poll — it does not wait.
*
* The form field is `files` and it is REPEATED, once per file. The older
* endpoint took a single `file`, and sending that name here parses as no files
* at all.
*/
export async function submitBatch(options: SubmitOptions): Promise<IngestBatch> {
const { files, sender = 'nearle-console', signal } = options;
guardFiles(files);
const form = new FormData();
for (const file of files) form.append('files', file, file.name);
// Labels the drop in the review inbox. The endpoint takes no credential, so
// without this every submission arrives as "anonymous" and the admin deciding
// whether to run it cannot tell ours from anyone else's.
form.append('sender', sender);
// `use_llm` and `fetch_images` are no longer sent, and passing them is inert.
//
// They decide how a run behaves and commit the host to outbound work — image
// search is minutes per batch on one vCPU — so the choice belongs to the admin
// pressing Start, not to whoever dropped the file. Keeping them in the request
// would have read like control we do not have.
let response: Response;
try {
response = await fetch(`${INGEST_BASE}${ROOT}`, {
method: 'POST',
body: form,
// Content-Type is deliberately unset: the browser adds it WITH the
// multipart boundary. Setting it by hand omits the boundary and the
// server parses nothing.
headers: { Accept: 'application/json' },
...(signal ? { signal } : {}),
});
} catch (cause) {
throw new IngestError(
cause instanceof DOMException && cause.name === 'AbortError'
? 'Cancelled.'
: 'Could not reach the ingest service.',
0,
);
}
return readResponse<IngestBatch>(response);
}
/** One poll. */
export async function fetchBatch(batchId: string, signal?: AbortSignal): Promise<IngestBatch> {
let response: Response;
try {
response = await fetch(`${INGEST_BASE}${ROOT}/${encodeURIComponent(batchId)}`, {
headers: { Accept: 'application/json' },
...(signal ? { signal } : {}),
});
} catch {
throw new IngestError('Lost contact with the ingest service while waiting.', 0);
}
return readResponse<IngestBatch>(response);
}
/**
* True when the batch is sitting in the review inbox, untouched.
*
* Not a failure and not a result — it is waiting for a person. The distinction
* has to be explicit, because the two obvious ways to classify it are both
* wrong: called finished, the screen reports an import of zero products that
* never ran; called in-progress, the browser polls indefinitely for something
* only an admin can move.
*/
export function isAwaitingReview(batch: IngestBatch): boolean {
return batch.status === 'pending' && !releasedRunId(batch);
}
/**
* The run a released drop became, if an admin has accepted it.
*
* A drop is a submission, not a run. Releasing it starts a separate batch and
* records its id on the file as `released_to`; the drop id keeps working and
* keeps saying `released`, so the results are one hop away rather than at the
* id you already hold.
*
* Read off the files rather than the drop, because that is where the service
* puts it — a drop of several files can in principle be released in parts.
*/
export function releasedRunId(batch: IngestBatch): string | null {
for (const file of batch.files ?? []) {
if (file.released_to) return file.released_to;
}
return null;
}
/**
* True when an admin declined the drop. Nothing further will ever arrive, so a
* client that keeps polling is waiting for something that cannot happen.
*/
export function isDismissed(batch: IngestBatch): boolean {
const files = batch.files ?? [];
return files.length > 0 && files.every((file) => file.status === 'dismissed');
}
/**
* Follows a drop to its run, once, and returns whichever is the real answer.
*
* The caller polls a drop id. If it is released, the numbers it wants are on
* the RUN — so this hops and returns that instead. Everything else comes back
* unchanged, so a caller never has to know a drop and a run are different
* things.
*/
export async function resolveBatch(batch: IngestBatch, signal?: AbortSignal): Promise<IngestBatch> {
const runId = releasedRunId(batch);
if (!runId || runId === batch.batch_id) return batch;
try {
return await fetchBatch(runId, signal);
} catch {
// The drop is still the honest answer if the run cannot be read — better a
// stale "released" than an error for something that did succeed.
return batch;
}
}
/**
* The products a finished run wrote, optionally narrowed to our own files.
*
* `filenames` is not optional in practice and should always be passed. An admin
* can assemble ONE run from several drops — the owning team's own words: "a run
* an admin assembled from several drops lists every file in it, so you may see
* filenames batched alongside your own" — so a run's manifest can contain other
* senders' products.
*
* Reading all of them was a real hazard, not a tidiness point. The sheet's price
* and opening stock are applied to whatever the manifest is matched against, so
* a product from someone else's sheet sharing a name with one of our rows would
* have been priced and stocked from OUR file, into OUR merchant's branch.
*
* Filtering by filename is the best this contract allows and it is not airtight:
* two senders can both upload `products.csv`. Narrowing by drop would be exact,
* and the run's files carry no drop reference to narrow by — worth asking for.
*/
export function productsOf(batch: IngestBatch, filenames?: readonly string[]): IngestProduct[] {
const wanted = filenames ? new Set(filenames) : null;
return (batch.files ?? [])
.filter((file) => !wanted || wanted.has(file.filename))
.flatMap((file) => file.result?.products ?? []);
}
/**
* True once the batch has stopped moving, whatever the outcome.
*
* Listed positively rather than as "not queued and not running". The negative
* form silently absorbed every status added later — which is exactly how
* `pending` came to read as a completed import the day the review inbox
* appeared. A new status now shows up as "not settled" and stalls a spinner,
* which is visible, rather than as "done" and fabricates a result.
*/
export function isSettled(batch: IngestBatch): boolean {
// A retired drop whose files went nowhere we can follow is over. Normally
// resolveBatch has already hopped to the run, or isDismissed has caught a
// decline — this is the remainder, and leaving it unsettled would spin a
// progress bar on a drop that no longer exists.
if (batch.status === 'retired') {
return !releasedRunId(batch);
}
return (
batch.status === 'done' ||
batch.status === 'partial' ||
batch.status === 'failed' ||
batch.status === 'interrupted' ||
batch.status === 'cancelled'
);
}
/** Sleeps, unless the caller aborts first. */
function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(finish, ms);
function finish() {
clearTimeout(timer);
signal?.removeEventListener('abort', finish);
resolve();
}
signal?.addEventListener('abort', finish, { once: true });
});
}
/** Resolves the moment the tab is visible again — immediately if it already is. */
function whenVisible(signal?: AbortSignal): Promise<void> {
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
return Promise.resolve();
}
return new Promise((resolve) => {
const finish = () => {
if (document.visibilityState !== 'visible' && !signal?.aborted) return;
document.removeEventListener('visibilitychange', finish);
signal?.removeEventListener('abort', finish);
resolve();
};
document.addEventListener('visibilitychange', finish);
signal?.addEventListener('abort', finish, { once: true });
});
}
/** How long to wait before the next reading, given where the batch has got to. */
export function pollDelayFor(batch: IngestBatch): number {
// A run is minutes and a person is watching the stage name move.
if (!isAwaitingReview(batch)) return 2000;
// A review hold is however long their admin takes — sometimes hours. Two
// seconds against that is 1,800 requests an hour to be told "still waiting".
return 15000;
}
/**
* Polls until the batch is finished, THROUGH the review hold.
*
* ── Why it no longer stops at "awaiting review" ─────────────────────────────
*
* It used to return there, on the reasoning that waiting for an admin is not
* progress. True, but it left the console showing "waiting for review" forever
* once that admin released the drop — the steps only moved when the operator
* reloaded the page, which is the one thing a step-by-step progress panel is
* supposed to save them from. The release is exactly the transition worth
* watching: it is when the drop becomes a run and the products start arriving.
*
* So the hold is polled too, at `pollDelayFor`'s slower cadence — 15s rather
* than 2s, because a hold can last hours and a person is not watching a bar
* during one.
*
* ── And why a hidden tab costs nothing ──────────────────────────────────────
*
* Polling pauses entirely while the tab is in the background and takes a
* reading the instant it comes forward. So a sheet left open in another tab all
* afternoon makes no requests, and is up to date by the time the operator has
* looked at it — which is the same thing they used to get from reloading, minus
* the reload.
*
* `onTick` fires on each reading so the caller renders stage names and row
* counts as they move. It stops on a result and on a dismissal: a declined drop
* will never produce one.
*/
export async function pollBatch(
batchId: string,
onTick: (batch: IngestBatch) => void,
signal?: AbortSignal,
): Promise<IngestBatch> {
for (;;) {
if (signal?.aborted) throw new IngestError('Cancelled.', 0);
// Follows a released drop to the run it became, so the caller polls the
// thing that actually has progress on it rather than a record that will say
// "released" forever.
const batch = await resolveBatch(await fetchBatch(batchId, signal), signal);
onTick(batch);
if (isSettled(batch) || isDismissed(batch)) return batch;
await wait(pollDelayFor(batch), signal);
await whenVisible(signal);
}
}
/* ── Reading a response ───────────────────────────────────────────────────── */
async function readResponse<T>(response: Response): Promise<T> {
const text = await response.text();
let payload: unknown = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) throw describe(response.status, payload, text);
return payload as T;
}
/**
* `detail` is a STRING on some failures and an OBJECT on others.
*
* 400 and 413 send a sentence; 422 sends `{message, rows_total, errors[]}`.
* Rendering it straight prints "[object Object]" for exactly the response that
* carries the most useful information, so both shapes are unpacked here.
*/
function detailOf(payload: unknown): string | undefined {
if (payload === null || typeof payload !== 'object') return undefined;
const detail = (payload as { detail?: unknown }).detail;
if (typeof detail === 'string') return detail;
if (detail !== null && typeof detail === 'object') {
const nested = detail as { message?: unknown; errors?: unknown };
const message = typeof nested.message === 'string' ? nested.message : undefined;
const errors = Array.isArray(nested.errors) ? nested.errors : [];
// Row numbers are what makes a 422 actionable — they are the sheet's own
// 1-based numbering, header included, so they match what the operator sees.
const rows = errors
.slice(0, 5)
.map((entry) => {
const row = (entry as { row?: unknown }).row;
const error = (entry as { error?: unknown }).error;
return `row ${String(row)}: ${String(error)}`;
})
.join(' · ');
return [message, rows].filter(Boolean).join(' — ') || undefined;
}
return undefined;
}
/**
* The service's failures, in words that name the fix.
*
* Each of these has one cause and one remedy, and a generic "request failed"
* sends people to look at their spreadsheet for a problem that is in the
* deployment.
*/
function describe(status: number, payload: unknown, text: string): IngestError {
const detail = detailOf(payload);
const body = text.slice(0, 2000);
if (status === 401) {
// Deliberately NOT `detail ?? …`: the service answers both a missing key
// and a malformed one with a flat "Invalid API key.", which is true and
// tells nobody what to change.
return new IngestError(
'The ingest service rejected the credential. INGEST_TOKEN must be the SECRET ONLY — the 43-character value, not the `name:role:secret` triple, which fails as an invalid key rather than a malformed one. Set it on the container and restart; envsubst runs at container start, so a running container will not pick it up.',
status,
body,
);
}
if (status === 403) {
return new IngestError(
detail ??
'That key authenticated but does not hold `upload_catalog`. It needs the `uploader` or `admin` role.',
status,
body,
);
}
if (status === 413) {
return new IngestError(
detail ?? 'Too large for the service — 20 files, 10 MB each, 50 MB and 20,000 rows per upload.',
status,
body,
);
}
if (status === 429) {
return new IngestError(
detail ??
'The review inbox is full, so NOTHING was stored — this upload was not merely delayed. An admin has to clear it before you resend.',
status,
body,
);
}
if (status === 400) {
return new IngestError(
detail ??
'The service could not read that file. Every sheet needs a product-name column — product, item, variant or name.',
status,
body,
);
}
return new IngestError(detail ?? `The ingest service returned HTTP ${status}.`, status, body);
}
/* ── Reading a finished batch ─────────────────────────────────────────────── */
/** True when the batch ended without everything landing. */
export function isIncomplete(batch: IngestBatch): boolean {
return (
batch.status === 'partial' ||
batch.status === 'failed' ||
batch.status === 'interrupted' ||
batch.status === 'cancelled' ||
batch.files_failed > 0
);
}
/** One line for the top of the result panel. */
export function summarise(batch: IngestBatch): string {
const { totals } = batch;
if (isDismissed(batch)) {
// A refusal, not a failure, and nothing further is coming.
//
// The DROP-level detail is deliberately not used here. It still reads
// "Waiting for review. Nothing runs until an admin starts it." on a drop
// that has since been declined — the sentence was written when the file was
// accepted and nothing rewrites it. Rendering it would tell the operator to
// keep waiting for a decision that has already been made against them.
//
// A reason attached to the FILE is the admin's own and is worth showing.
const reason = (batch.files ?? []).map((file) => file.detail).find(Boolean);
return reason
? `An admin declined this upload: ${reason}`
: 'An admin declined this upload. Nothing was imported.';
}
if (isAwaitingReview(batch)) {
// The service's own sentence when it has one — it is clearer than anything
// invented here, and it changes if their review policy does.
return (
batch.detail ??
'Waiting for review. Nothing runs until an admin on the ingest service starts it.'
);
}
if (batch.status === 'failed') {
return batch.detail ?? 'No file could be ingested.';
}
if (batch.status === 'interrupted') {
return 'The service restarted part-way through. An admin can resume this batch — it will not restart on its own.';
}
if (batch.status === 'cancelled') {
return 'This batch was cancelled before every file ran.';
}
const parts = [`${totals?.inserted ?? 0} added`];
if ((totals?.backfilled ?? 0) > 0) parts.push(`${totals.backfilled} filled in`);
if ((totals?.skipped_existing ?? 0) > 0) parts.push(`${totals.skipped_existing} already there`);
if ((totals?.rejected ?? 0) > 0) parts.push(`${totals.rejected} rejected`);
const summary = parts.join(' · ');
return batch.files_failed > 0
? `${summary} — but ${batch.files_failed} of ${batch.files_total} files could not be read`
: summary;
}
/** Overall progress, for a bar. Stages within a file are too fine to show. */
export function progressOf(batch: IngestBatch): { done: number; total: number } {
return { done: batch.files_done + batch.files_failed, total: batch.files_total };
}
/**
* True when the batch was handed to an orchestrator that is not there.
*
* `runner: "dagster"` never runs in production: Dagster is a development tool,
* absent from the deployed image, so the batch waits for a worker that will
* never claim it and sits at `queued` indefinitely. It has to be named, because
* every visible signal — a queued status, a stage index of 0, a progress bar at
* nothing — is identical to a batch that is merely waiting its turn.
*
* Only meaningful while it is still waiting. A batch that reached `running`
* plainly found an executor whatever it was staged for.
*/
export function isStuckOnMissingRunner(batch: IngestBatch): boolean {
return batch.runner === 'dagster' && (batch.status === 'queued' || batch.status === 'pending');
}
/**
* Where a file is in the eleven stages, read from the timeline rather than the
* scalars.
*
* `stages[]` is the authority: the entry with `finished_at: null` is the stage
* running now. `stage_index`/`stage_name` describe the same moment and are used
* as a fallback for a service build that does not send the timeline, but they
* cannot show a finished file's history and the timeline can.
*
* Returns null when there is nothing to draw — a file that has not started, or
* one from a response carrying neither.
*/
export function currentStage(file: BatchFile): BatchStage | null {
const running = (file.stages ?? []).find((stage) => stage.finished_at == null);
if (running) return running;
// Finished, or a build without the timeline. The last entered stage is the
// most useful thing to show for a file that has stopped moving.
const last = (file.stages ?? []).at(-1);
if (last) return last;
if (!file.stage_index) return null;
return {
index: file.stage_index,
name: file.stage_name ?? `Stage ${file.stage_index}`,
...(file.rows_done === undefined ? {} : { rows_done: file.rows_done }),
...(file.rows_total === undefined ? {} : { rows_total: file.rows_total }),
};
}

262
src/api/insights.ts Normal file
View File

@@ -0,0 +1,262 @@
/**
* Order, delivery and POS reads — the numbers behind store performance.
*
* Note the split: online orders come from `/web/orders/*` and counter sales
* from `/pos/*`, which is the ONLY part of the API carrying auth middleware.
* Any figure that blends the two is eventually consistent by construction, so
* screens that show one must also show its freshness.
*/
import { api, POS, WEB } from './client';
import type {
DeliveryRow,
DeliverySummary,
LocationOrderSummary,
OrderItem,
OrderRow,
OrderSummary,
PosLocationHealth,
PosTerminalHealth,
PosSalesPage,
PosSalesSummary,
} from './types';
export interface DateRange {
fromdate?: string;
todate?: string;
}
export interface OrderQuery extends DateRange {
tenantid: number;
/** Omit for every branch of the tenant. */
locationid?: number;
/**
* One delivery partner's work, ACROSS every merchant they serve.
*
* The platform's own view of dispatch: a partner's riders carry for many
* shops at once — partner 60 answered with 376 deliveries spanning 12
* merchants — and no tenant-scoped read can show that. Verified live on
* 2026-09-11.
*
* Never sent alongside a tenantid. The endpoint treats the two as separate
* doors onto the same table, not as filters that combine.
*/
partnerid?: number;
status?: string;
keyword?: string;
pageno?: number;
pagesize?: number;
}
export const insightsApi = {
/**
* The order rows themselves.
*
* `/orders/tenant/getorders` rather than the bare `/orders/getorders`: the
* controller routes on which ids are present, and passing a tenant with no
* partner, customer or app-user reaches `GetTenantOrders`. Passing a
* `locationid` as well reaches `GetTenantLocationOrders`, which is the
* branch-scoped read — so one call covers both "all branches" and "one
* branch" by presence alone.
*
* `pageno` is 1-based here. The controller floors anything <= 0 to 1, so
* sending 0 silently gives page one rather than an error.
*/
orders: (query: OrderQuery) =>
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
fromdate: query.fromdate,
todate: query.todate,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 50,
}),
/**
* Every order in a window, not the first page of them.
*
* Reports totals its figures from order ROWS — `getlocationsummary` carries no
* money and ignores the date picker, so the rows are the only source that both
* has revenue and respects the range. Reducing over a single `pagesize: 500`
* read made every one of those figures a silent lie the moment a tenant traded
* more than five hundred orders in the window: the page showed a total, gave no
* sign it was a partial one, and `api.list` discards the envelope so nothing
* downstream could even detect the cut.
*
* Paging stops on a SHORT PAGE rather than on a count the list endpoint does
* not return — the same rule `catalogue.idsByImageId` follows, and for the same
* reason: a total we would have to trust is worse than a page we can measure.
*
* `maxPages` is a real bound, not a formality. Something has to stop a loop
* pointed at production, and a window wide enough to exceed it is a window the
* reader should be told about rather than one we quietly keep fetching. Hence
* `truncated`, which the caller is expected to surface — the whole point of
* this function is that a partial total never again passes for a complete one.
*/
ordersAll: async (
query: OrderQuery,
{ pagesize = 500, maxPages = 10 }: { pagesize?: number; maxPages?: number } = {},
): Promise<{ rows: OrderRow[]; truncated: boolean }> => {
const rows: OrderRow[] = [];
/* `pageno` is 1-based on this endpoint — the controller floors <= 0 to 1. */
for (let page = 1; page <= maxPages; page += 1) {
const batch = await insightsApi.orders({ ...query, pageno: page, pagesize });
rows.push(...batch);
if (batch.length < pagesize) return { rows, truncated: false };
}
return { rows, truncated: true };
},
/**
* The delivery jobs.
*
* A separate read from the orders list, NOT a filter over it. The rows are a
* different struct with different fields — rider name, planned vs actual
* distance, rider charge vs job value, notes — and a different status ladder.
* Deriving deliveries from orders, which is what this page did first, loses
* every one of those.
*
* The controller 400s unless one of tenantid/partnerid/customerid/
* applocationid/userid/appuserid is present, so `tenantid` is required here.
*/
deliveries: (query: OrderQuery) =>
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
fromdate: query.fromdate,
todate: query.todate,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 50,
}),
orderSummary: (tenantid: number, range: DateRange = {}) =>
api.get<OrderSummary>(`${WEB}/orders/getordersummary`, { tenantid, ...range }),
/** Per-branch order totals for one tenant. `tenantid` is required. */
locationSummary: (tenantid: number, range: DateRange = {}) =>
api.list<LocationOrderSummary>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
revenueSummary: (tenantid: number, range: DateRange = {}) =>
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
/**
* `granularity` is REQUIRED and was never sent, so this call answered 400
* every single time: "granularity query parameter is required (day, month,
* year)". Nothing renders it yet, which is the only reason it went unnoticed
* — the first screen to use it would have shown an error instead of a chart.
*
* Defaulted rather than made a required argument: a day-by-day series is what
* every caller of a dated range wants, and a parameter with one sensible
* answer should not be every caller's problem.
*/
timeSeries: (
tenantid: number,
range: DateRange = {},
granularity: 'day' | 'month' | 'year' = 'day',
) =>
api.get<Record<string, unknown>[]>(`${WEB}/orders/gettimeseries`, {
tenantid,
granularity,
...range,
}),
/**
* What is actually IN an order.
*
* The only read that carries line items. Every list endpoint returns an
* order's totals and never its contents, which is why the detail sheet could
* say an order was worth ₹840 and not what the ₹840 bought.
*
* The envelope rather than `api.list`, because the authoritative total lives
* outside `details`: `OrderDetail.Orderamount` is `json:"-"` on the server, so
* `pricedetails.orderamount` is the only place it appears. Summing the lines
* would be recomputing a figure Fiesta has already worked out, and the two
* would disagree the first time a discount rounded differently.
*/
orderItems: async (orderheaderid: number) => {
const envelope = await api.envelope<OrderItem[]>(`${WEB}/orders/getorderdetails`, {
params: { orderheaderid },
});
return {
// `details: null` for an order with no lines is as common here as `[]`;
// see the note on `api.list`.
items: envelope.details ?? [],
amount: envelope.pricedetails?.orderamount ?? 0,
tax: envelope.pricedetails?.totaltaxamount ?? 0,
};
},
deliverySummary: (tenantid: number, range: DateRange = {}) =>
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
/**
* Counter sales for ONE outlet.
*
* `locationid` is required and singular — there is no tenant-wide POS call,
* so a multi-branch view fans out one request per branch.
*/
posSales: (
locationid: number,
params: DateRange & {
pageno?: number;
pagesize?: number;
/** The three server-side filters the old console's bills tab offers. */
terminalid?: string;
cashiername?: string;
paymentmode?: string;
} = {},
) =>
api.get<PosSalesPage>(`${POS}/sales`, {
locationid,
pageno: params.pageno ?? 0,
pagesize: params.pagesize ?? 50,
fromdate: params.fromdate,
todate: params.todate,
terminalid: params.terminalid,
cashiername: params.cashiername,
paymentmode: params.paymentmode,
}),
/**
* One counter bill in full, with its lines.
*
* `reference` accepts the till's order UUID, the invoice number, or this
* backend's posorderid — a support call starts from whichever the person is
* looking at, so the endpoint takes all three.
*/
posSaleDetail: (locationid: number, reference: string) =>
api.get<unknown>(`${POS}/sales/detail`, { locationid, reference }),
/**
* Counter-sales totals for ONE outlet.
*
* The richest read in the API: bill count, gross, tax, discount, roundoff and
* average bill, already broken down by payment mode, by day and by terminal.
* Reports gets its offline half from this and nothing else.
*
* Kept apart from the order summaries above on purpose. These are `posorders`
* rows; those are `orders` rows. The two are never added together — see
* `store-admin-backend-gap.md` §3.1 for why that would double-count a branch
* that both runs a till and uploads a spreadsheet.
*/
posSalesSummary: (locationid: number, range: DateRange = {}) =>
api.get<PosSalesSummary>(`${POS}/sales/summary`, { locationid, ...range }),
/**
* Till presence for one outlet — how many are online, how many bills are stranded.
*
* Returns the terminal list, not the wrapper. The endpoint answers
* `{location_id, total, online, terminals}`; every caller wants `terminals`,
* and `summariseBranch` recomputes `online` from the heartbeats anyway
* because Fiesta's figure counts a stale till as present. Unwrapping here
* keeps that one shape fact in the API layer instead of on every page.
*/
posHealth: (locationid: number): Promise<PosTerminalHealth[]> =>
api
.get<PosLocationHealth>(`${POS}/health/location`, { location_id: locationid })
.then((health) => (Array.isArray(health?.terminals) ? health.terminals : [])),
};

166
src/api/nutrition.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* Health scores and nutrition, from the catalogue-intelligence service.
*
* A SEPARATE HOST from Fiesta — `mcp.nearle.ai.in`, the same service that
* scrapes the global catalogue — so it does not go through `client.ts`, which
* exists to talk to one backend. It is read-only and unauthenticated, like the
* catalogue reads beside it.
*
* ── The join key ────────────────────────────────────────────────────────────
*
* `image_id`, not `catalogueid`. That is the same stable key the catalogue
* import already uses, and for the same reason: `catalogueid` is renumbered on
* every re-scrape, so a link made through it goes stale silently. A product
* carries its `imageid` from the import, and that is what resolves here.
*
* ── Two things measured against the live service, 4 Sep 2026 ────────────────
*
* - `include_unknown=true` is REQUIRED or the list returns nothing. With it,
* 252 items; without it, zero — including products whose `data_status` is
* "verified" and whose score is a real number. The flag reads like it should
* only add unscored rows; in practice its absence removes everything.
*
* - Scoring covers ten brands (Nestle, Amul, Coca-Cola, Cadbury and six
* smaller ones). None of the brands our merchants actually stock are among
* them yet, so today this renders on no products at all. The wiring is
* correct; the data has to catch up.
*/
const NUTRITION_BASE = 'https://mcp.nearle.ai.in/api';
/** How confident the service is that it matched the right source record. */
export const LOW_CONFIDENCE = 0.7;
export interface NutritionScore {
brand?: string;
image_id?: string;
product_name?: string;
category?: string;
/** 0–100. `null` when the product is known but has not been scored. */
health_score?: number | null;
nutrition_score?: number | null;
health_band?: string | null;
scoring_version?: string | null;
/** Sentences, already written for a person. Rendered as given. */
positive_insights?: string[];
nutritional_cautions?: string[];
ai_summary?: string | null;
diet_tags?: string[];
allergens?: string[];
/** "verified" when the source record was confirmed. */
data_status?: string | null;
data_source?: string | null;
source_url?: string | null;
/**
* 0–1. The 5 Star record scores 0.577 — a moderate match, not a certainty.
*
* Surfaced rather than hidden. A nutrition panel presented as fact when the
* underlying match is a guess is worse than no panel, and that goes double
* for the allergen list.
*/
match_confidence?: number | null;
serving_size_g?: number | null;
serving_size_label?: string | null;
calories_kcal?: number | null;
protein_g?: number | null;
carbohydrates_g?: number | null;
total_sugar_g?: number | null;
added_sugar_g?: number | null;
dietary_fiber_g?: number | null;
total_fat_g?: number | null;
saturated_fat_g?: number | null;
sodium_mg?: number | null;
}
async function read<T>(path: string): Promise<T | null> {
let response: Response;
try {
response = await fetch(`${NUTRITION_BASE}${path}`, {
headers: { Accept: 'application/json' },
});
} catch {
// A nutrition panel is an enhancement on a product page. If the service is
// unreachable the page still has to render, so this reports "nothing"
// rather than throwing into the drawer.
return null;
}
if (!response.ok) return null;
try {
return (await response.json()) as T;
} catch {
return null;
}
}
/**
* Their brand spelling, resolved from ours.
*
* The two catalogues agree on every brand and disagree on how to write it:
*
* ours theirs
* cadbury → Cadbury
* coca_cola → Coca-Cola underscore becomes a HYPHEN
* brooke_bond → Brooke Bond underscore becomes a SPACE
* 24_mantra → 24 Mantra
*
* Which separator an underscore becomes cannot be derived — it is a hyphen for
* Coca-Cola and Colgate-Palmolive and a space for everything else. So the list
* is fetched and matched on a normalised form rather than guessed at.
*
* This is not cosmetic. `GET /nutrition/cadbury/...` returns
* `health_score: null` — a well-formed answer meaning "no score", not an error
* — so getting the case wrong looks exactly like a product nobody has scored,
* on every product, forever.
*
* Cached for the process: a brand list changes when the scraper learns a new
* brand, which is not during a session.
*/
let brandsPromise: Promise<string[]> | null = null;
function normalise(brand: string): string {
return brand.toLowerCase().replace(/[^a-z0-9]/g, '');
}
async function resolveBrand(raw: string): Promise<string> {
const wanted = normalise(raw);
if (!wanted) return raw;
brandsPromise ??= read<{ brands?: string[] }>('/brands').then((r) => r?.brands ?? []);
const brands = await brandsPromise;
// Their exact spelling if we know it; ours unchanged if we do not, so a brand
// they have not listed still gets a real attempt rather than being dropped.
return brands.find((candidate) => normalise(candidate) === wanted) ?? raw;
}
export const nutritionApi = {
/**
* One product's score and nutrition.
*
* Returns null when the product is unknown to the service, and a record with
* `health_score: null` when it is known but unscored — two different answers
* that must not be collapsed, because the second means "coming soon" and the
* first means "this product was never in the catalogue".
*/
forProduct: async (brand: string, imageId: string) => {
// Resolved first: our catalogue spells brands in snake_case and theirs does
// not, and the mismatch reads as "unscored" rather than as an error.
const resolved = await resolveBrand(brand);
return read<NutritionScore>(
`/nutrition/${encodeURIComponent(resolved)}/${encodeURIComponent(imageId)}`,
);
},
};
/** Exposed for the check script, which asserts the two vocabularies still line up. */
export const __resolveBrand = resolveBrand;
/** True when the service knows the product but has not scored it yet. */
export function isUnscored(score: NutritionScore | null): boolean {
return score !== null && (score.health_score === null || score.health_score === undefined);
}

244
src/api/optimiser.ts Normal file
View File

@@ -0,0 +1,244 @@
import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign';
import type { OrderRow } from './types';
/**
* The route optimiser.
*
* A SEPARATE SERVICE from Fiesta — `routes.workolik.com`, "Route Optimization
* API v2.0.0" — so it does not go through `client.ts`, which exists to talk to
* one backend. Road routing is real (a Valhalla backend, not straight lines)
* and the assignment model is trained: 3,627 records, tuned to 20 orders per
* rider and an ideal load of 4.
*
* ── What it is and is not ───────────────────────────────────────────────────
*
* `optimization/createdeliveries` is NOT a create, despite the name it shares
* with Fiesta's. It is a pure function: send an array of orders, get the same
* array back reordered nearest-neighbour with `step`, `previouskms`,
* `cumulativekms`, `actualkms` and `eta` added. Its own docs say forwarding is
* paused, and the verified behaviour matches — it writes nothing anywhere.
*
* So the sequence is ours to commit: we take its answer and post it to Fiesta's
* `deliveries/createdeliveries` ourselves. That is also what the xpress console
* does, which is the only reason its two identically-named endpoints do not
* collide.
*
* ── `riderassign` assigns against OUR fleet, not a foreign one ──────────────
*
* This file used to say the opposite — that `riderassign` was useless because
* it returned orders assigned to `rider_id 883, "Rajan A"`, "not one of ours".
* That was wrong, and it was wrong for the ordinary reason: an unfamiliar id
* was taken for a stranger without checking the roster.
*
* Checked on 2026-09-10. `getriderroster?partnerid=44` lists 883 "Rajan A", and
* so do the rider ids on tenant 916's own delivery rows — 883, 897, 950, 1111,
* 1114, every one of them partner 44's, which is the Coimbatore fleet. The
* solver reads the same database Fiesta does: `getallriders` on jupiter and
* `getriders` on Fiesta return identical rosters and identical on-duty state.
*
* So auto-assignment works and `assign` below wires it up.
*
* ── What it cannot do yet, and why that is not our bug ──────────────────────
*
* The solver picks the riders itself, gated on `onduty = 1`, and that flag is 0
* for all 118 riders on the platform — every region, checked the same day. So
* `active_riders_pool` is 0 and every order comes back unassigned with "No
* riders found (check partner online status)". Supplying riders in the body
* does not help: `riders` and `active_riders` were both tried against a rider
* the on-duty endpoint DOES report, and the pool stayed 0.
*
* Whatever is meant to set `onduty` is not setting it. That is worth asking the
* app team about; nothing here can work around it.
*
* ── `routemate` is gone ─────────────────────────────────────────────────────
*
* The old console's second mode posted to `routemate.workolik.com/api/v1/
* optimization/riderassign?strategy=multi_trip`, which accepted a rider list
* inline. It answers 404 now, with and without the query string, so that route
* around the `onduty` gate is closed too.
*/
const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1';
/** A solve can legitimately take a while. Past this, something is wrong. */
const SOLVE_TIMEOUT_MS = 90_000;
/** An order as the optimiser hands it back — ours, plus the routing it added. */
export interface SequencedStop extends OrderRow {
/** 1..N. The order to visit in. */
step?: number;
/** Kilometres from the previous stop. */
previouskms?: number;
/** Running total for the round. */
cumulativekms?: number;
/**
* Direct pickup-to-delivery distance, as a string.
*
* The service returns these as strings ("1.23"), which is also how Fiesta's
* `deliveries.kms` / `actualkms` columns are typed — so they carry across
* unconverted. Those columns are exactly the ones found holding the literal
* text "null" in production, which broke the rider summary; a sequence run is
* what should be filling them with real numbers.
*/
actualkms?: string;
kms?: string;
/** Minutes for this leg, and cumulative. Strings, as sent. */
eta?: string;
cumulative_eta?: string;
ordertype?: string;
}
/** One rider's leg of a plan, in the shape reconcile expects back. */
export interface PlannedRider {
rider_id: string | number;
rider_name?: string;
orders: SequencedStop[];
}
export class OptimiserError extends Error {
constructor(message: string) {
super(message);
this.name = 'OptimiserError';
}
}
async function post<T>(path: string, body: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`${OPTIMISER_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
} catch {
// A separate host means a separate failure mode: the optimiser can be down
// while Fiesta is fine. Said plainly so nobody debugs the wrong service.
throw new OptimiserError('Could not reach the route optimiser');
}
let payload: { code?: number; details?: T; message?: string; error?: { message?: string } };
try {
payload = await response.json();
} catch {
throw new OptimiserError(`The optimiser sent a malformed reply (HTTP ${response.status})`);
}
if (!response.ok) {
throw new OptimiserError(payload?.error?.message || payload?.message || `Optimiser refused the request (HTTP ${response.status})`);
}
// It answers `{code, details}` on the sequencing route and a bare object
// elsewhere, so both shapes are unwrapped here rather than at each call site.
return (payload.details ?? (payload as unknown)) as T;
}
/**
* A run that is allowed to take its time, and to be cancelled.
*
* Separate from `post` for two reasons: the caller needs the whole envelope
* rather than `details`, and a solve is slow enough that abandoning it has to
* be possible. The caller's cancel and the timeout both have to be able to stop
* it, so they are combined rather than one winning.
*/
async function postRaw(path: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
const timer = new AbortController();
const stop = setTimeout(() => timer.abort(), SOLVE_TIMEOUT_MS);
const onAbort = () => timer.abort();
signal?.addEventListener('abort', onAbort);
try {
const response = await fetch(`${OPTIMISER_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
signal: timer.signal,
});
if (!response.ok) {
// 422 is the solver rejecting the payload and saying which field. Worth
// showing verbatim — "422" on its own is not actionable.
const text = await response.text().catch(() => '');
throw new OptimiserError(
text.trim().slice(0, 400) || `Optimiser refused the request (HTTP ${response.status})`,
);
}
return await response.json();
} catch (error) {
if (error instanceof OptimiserError) throw error;
if ((error as Error)?.name === 'AbortError') {
throw new OptimiserError(
signal?.aborted
? 'Cancelled.'
: 'The optimiser did not answer in time. Nothing was assigned — the orders are untouched.',
);
}
throw new OptimiserError('Could not reach the route optimiser');
} finally {
clearTimeout(stop);
signal?.removeEventListener('abort', onAbort);
}
}
export const optimiserApi = {
/**
* Put a set of orders in a sensible order.
*
* Send them in any order; they come back sorted with a step number and the
* distance and time between each. Verified against the live service with our
* own field names — it reads `pickuplat`/`pickuplong` and
* `deliverylat`/`deliverylong`, which order rows already carry.
*/
sequence: (orders: OrderRow[]) =>
post<SequencedStop[]>('/optimization/createdeliveries', orders),
/**
* Repair step numbers after somebody moved a stop by hand.
*
* Moving one order between riders breaks two rounds at once: the rider who
* lost it has a hole in its sequence (1,2,3,5,6) and the one who gained it
* has a step that collides or is missing. This fixes both.
*
* It MUST run before the plan is committed. The team who built the page this
* came from call skipping it "the single biggest production bug to avoid in
* this area" — it corrupts route sequences in the database, and nothing at
* the point of the write can tell.
*
* Send only the riders that were edited; the response carries those riders
* back and the rest of the plan is left alone.
*/
reconcile: (riders: PlannedRider[]) =>
post<{ riders: PlannedRider[] }>('/optimization/reconcile-steps', { riders }),
/**
* Propose a rider for each waiting order.
*
* A PLAN, not a commitment. Nothing is written anywhere until the operator
* accepts it and the console makes its own `createdeliveries` call to Fiesta
* through `buildDelivery` — the same path the manual assign bar uses, so
* there is exactly one way a delivery is ever written. Safe to run twice and
* safe to walk away from.
*
* ── Raw, not unwrapped ────────────────────────────────────────────────────
*
* `postRaw`, because the answer here IS the envelope: `zones` carries the
* assignment, `meta` carries the accounting and the per-order reasons, and
* `details` is only the flat fallback shape. `post` would hand back `details`
* alone and throw the plan away — and `details` is `[]` on every run that
* assigns nothing, which is every run today.
*
* ── Slow on purpose ───────────────────────────────────────────────────────
*
* Seven seconds for five orders, measured, and it is a solver so it grows
* with the problem. `signal` is taken so a caller can offer to cancel; the
* timeout is deliberately generous, since killing a run early abandons work
* the operator is waiting on and teaches them the button is broken.
*
* `tuning` steers it — balanced, aggressive_speed, fuel_saver, zone_strict —
* and the literal string `null` is a value it accepts, meaning "your default".
*/
assign: (request: SolverRequest, tuning: Tuning | null, signal?: AbortSignal) =>
postRaw(
`/optimization/riderassign?hypertuning_params=${tuning ?? 'null'}`,
request,
signal,
),
};

252
src/api/people.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* People — back-office staff and till accounts.
*
* Two account systems that happen to share one table. A till account is NOT a
* Nearle Daily user: the backend excludes roles 7 and 8 from every application
* lookup inside the query itself, deliberately, so a cashier is "not found"
* rather than "refused". They are read and written through different endpoints
* with different conventions, and this file keeps them apart.
*
* Three things this module will not do, each for a reason recorded in
* `store-admin-user-menu-plan.md`:
*
* - **No delete.** `DELETE /users/delete` and `DELETE /deleteposuser` are hard
* deletes with no cascade. Deactivating via `status` is the safe equivalent
* and is what both list screens offer.
* - **No password management.** `PUT /users/update` doubles as the
* password-reset call and passwords are stored in clear. Creating an account
* is in scope; issuing its password is not, until hashing exists.
* - **Never send `roleid: -1`.** The old console clears a role that way;
* `UpdateStaff` does not special-case it, so `-1` lands in the column and the
* account ends up holding a role that matches nothing.
*/
import { api, WEB } from './client';
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
/* ── Back-office staff ───────────────────────────────────────────────────── */
export interface CreateStaffRequest {
tenantid: number;
locationid: number;
firstname: string;
lastname?: string;
email: string;
contactno: string;
roleid: number;
status?: string;
}
export interface UpdateStaffRequest {
userid: number;
firstname?: string;
lastname?: string;
email?: string;
contactno?: string;
roleid?: number;
locationid?: number;
status?: string;
}
export const staffApi = {
/**
* The tenant's back-office directory.
*
* `getstaffs`, NOT `getallusers`. This one resolves `rolename` server-side,
* and the backend says why that matters: "`app_roles` holds six rows for four
* back-office roles and most accounts carry an id absent from it, so any
* mapping written client-side is wrong."
*
* On WEB now. It used to be on MOB because `getstaffs` was registered under
* `/v1/mob/tenants` alone and had no `/web` twin — back-office staff were
* reachable only through the customer app's door, which is a large part of
* why this console never had a people screen. The twin now exists; the MOB
* registration is left in place in case something else calls it.
*
* Returns people with NO branch as well as people with one. That is the whole
* point: `locationid` 0 means hired and not yet placed, and the list is
* ordered to put them first, because they are the rows needing an action.
*/
list: (tenantid: number) =>
api.list<StaffInfo>(`${WEB}/tenants/getstaffs`, { tenantid }),
/**
* Put somebody at a branch, or take them off one.
*
* `unassign` is a separate flag rather than `locationid: 0`, deliberately. A
* body that lost the field, a form that posted a blank and a client that
* dropped it all arrive as 0 — so a zero alone must never mean "take them off
* their shop". The backend refuses it too; this mirrors the rule so the
* refusal is not a round trip.
*/
assign: (body: { tenantid: number; userid: number; locationid: number }) =>
api.put<unknown>(`${WEB}/tenants/assignstaff`, body),
unassign: (body: { tenantid: number; userid: number }) =>
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
/**
* Update a person.
*
* GORM's `Updates` with a struct skips zero values, so an omitted field is
* left alone rather than blanked — which is why every field here is optional
* and why clearing something is not possible through this call.
*/
update: (body: UpdateStaffRequest) => api.put<StaffInfo>(`${WEB}/users/update`, body),
};
/* ── Till accounts ───────────────────────────────────────────────────────── */
export interface CreatePosUserRequest {
tenantid: number;
locationid: number;
full_name: string;
/**
* The role NAME, lowercase — "supervisor" or "cashier".
*
* Not the id. `PosRoleFromName` reads the name off the request and returns 0
* for anything it does not recognise, which every caller treats as a refusal
* rather than as a default. Sending 7 or 8 here does nothing.
*/
role: string;
/** Ten digits. The till matches on it exactly — see the note in `normaliseMobile`. */
contactno: string;
pin?: string;
/** A `staffshifts.staff_shift_id`. Zero leaves it unset. */
shift_id?: number;
status?: string;
}
export interface UpdatePosUserRequest {
tenantid: number;
locationid: number;
user_id: number;
full_name?: string;
role?: string;
contactno?: string;
shift_id?: number;
status?: string;
}
export const posUsersApi = {
/**
* Till accounts at one outlet. `locationid` is required and singular.
*
* The envelope's `details` is an OBJECT — `{location_id, users}` — not the
* array it reads like (`posController.go:858-860`). Asking for it as a list
* returned an empty one every time, silently: the guard in `api.list` sees a
* non-array and hands back `[]`, so the page showed "no till accounts" for a
* shop that had them. Same shape trap as `/health/location`.
*/
list: (tenantid: number, locationid: number, includeInactive = false) =>
api
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
tenantid,
locationid,
/*
Off by default, because that is what every existing caller assumed.
The listing excludes inactive accounts unless asked
(`posUserRepository.go:494` — `LOWER(COALESCE(a.status,'active')) <>
'inactive'`), and `WebListPosUsers` reads `include_inactive` from the
query string. Nothing sent it, which had two consequences: a supervisor
who switched a cashier off in the drawer watched them disappear from the
list with no trace and no way back, and any Status column could only
ever render "Active" because that was the only status a row could have.
*/
...(includeInactive ? { include_inactive: 'true' } : {}),
})
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
/**
* The role picker's source.
*
* Read rather than hardcoded. Supervisor is 7 and Cashier is 8 today, but the
* endpoint also carries the label and the description a person needs to
* choose between them — and a third role would appear here first.
*/
roles: () => api.list<PosRole>(`${WEB}/tenants/posroles`),
/**
* Create a till account.
*
* The response carries the PIN or password ONCE. The backend is explicit that
* a listing never returns it: "An admin who loses it reissues rather than
* looks it up." So it is shown at creation and never read back.
*/
create: (body: CreatePosUserRequest) => api.post<PosUser>(`${WEB}/tenants/createposuser`, body),
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${WEB}/tenants/updateposuser`, body),
/**
* Shift windows a till account can be put on.
*
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
*/
/**
* The tenant's shift windows.
*
* `locationid` is optional and usually omitted. A shift belongs to the
* business, so the tenant's set is what every picker in the console should
* offer; naming a branch narrows to the tenant's plus that branch's own, for
* the outlet that genuinely runs different hours.
*/
shifts: (tenantid: number, locationid?: number) =>
api
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
tenantid,
...(locationid ? { locationid } : {}),
})
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
/**
* Open a shift window at one branch.
*
* The endpoint has existed since till staff were built; nothing in the console
* called it. So `getstaffshifts` answered `{"shifts": []}` at every branch —
* the comment on StoreStaffPage says exactly that — and the picker on this
* drawer offered "Any shift" and nothing else, for everyone, permanently.
*
* `weekdays` is a seven-character mask starting Monday; empty means every day.
* The server rejects anything that is not seven 0/1 characters, so it is sent
* as the mask rather than as a list the console would have to encode twice.
*/
createShift: (
tenantid: number,
shift: { name: string; start_time: string; end_time: string; weekdays?: string },
/** Only for a shop that genuinely runs different hours from the business. */
locationid?: number,
) =>
api.post<StaffShift>(`${WEB}/tenants/createstaffshift`, {
tenantid,
// Zero means the whole tenant, which is the ordinary case.
locationid: locationid ?? 0,
...shift,
}),
};
/**
* Ten digits, or nothing.
*
* The till matches the mobile number EXACTLY, so `+91 98765 43210` typed back
* as `9876543210` would not find the row. Stripping to the last ten digits at
* the edge means an admin can paste whatever their contact list gave them.
*/
export function normaliseMobile(input: string): string {
const digits = input.replace(/\D/g, '');
return digits.length > 10 ? digits.slice(-10) : digits;
}
/** Mon-first mask → "Mon–Fri", "Every day". `weekdays` empty means every day. */
export function weekdayLabel(mask: string | undefined): string {
if (!mask || !/^[01]{7}$/.test(mask)) return 'Every day';
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const on = [...mask].map((bit, index) => (bit === '1' ? days[index] : null)).filter(Boolean);
if (on.length === 7) return 'Every day';
if (on.length === 0) return '—';
if (mask === '1111100') return 'Mon–Fri';
if (mask === '0000011') return 'Weekends';
return on.join(', ');
}

435
src/api/products.ts Normal file
View File

@@ -0,0 +1,435 @@
/**
* Product, stock and import endpoints.
*
* Two import paths exist and they are NOT symmetric — the asymmetry is the
* backend's, not a choice made here:
*
* Catalogue path : one batch call, idempotent. Re-importing the same
* (tenantid, brand, catalogueid) tops up stock and
* overwrites price instead of duplicating.
*
* Sheet path : `create` accepts ONE product, not an array, and does not
* return the generated productid — the controller passes the
* struct to the service by value, so GORM writes the id into
* a copy that is then discarded, and the response echoes what
* was sent. So a sheet import is N creates, then a lookup by
* SKU to resolve ids, then one batched location call and one
* batched stock call.
*
* `importSheetProducts` below encapsulates that whole dance so no screen has to
* know about it.
*/
import { api, MOB, WEB } from './client';
import type {
ImportCatalogueProductRequest,
Product,
ProductCategory,
ProductLocationRequest,
ProductStockRequest,
ProductSubCategory,
} from './types';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
export interface LocationProductQuery {
tenantid: number;
locationid: number;
pageno?: number;
pagesize?: number;
}
export const productsApi = {
/**
* 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) =>
api.list<Product>(`${WEB}/products/getlocationproducts`, {
tenantid: query.tenantid,
locationid: query.locationid,
pageno: (query.pageno ?? 0) + 1,
pagesize: query.pagesize ?? 200,
}),
/**
* Every product a tenant owns, catalogue-imported or created.
*
* The payload is NOT a product list. It is `[]models.Tenantproducts` —
* `{tenant, products}` groups, one per tenant (`models/product.go:246`) — and
* it arrives under `data`, not `details`. Asked for as a flat list it handed
* back one wrapper object whose keys are `tenant` and `products`, which the
* SKU lookup in `importSheetProducts` then read as a product with no
* `productid`: every sheet import resolved zero ids and wrote no locations
* and no stock. Flattened here so no caller sees the grouping.
*
* Nothing calls this today — the importer that did now gets its ids from the
* create response. Kept because it is the only wrapper for a real endpoint
* and the grouping above is the sort of thing the next caller would be
* caught by all over again.
*/
allProducts: (tenantid: number) =>
api
.list<{ products?: Product[] }>(`${WEB}/products/getallproducts`, { tenantid })
.then((groups) => groups.flatMap((group) => group?.products ?? [])),
count: (tenantid: number) =>
api.get<{ count?: number }>(`${WEB}/products/getproductscount`, { tenantid }),
categories: (tenantid: number) =>
api.list<ProductCategory>(`${WEB}/products/getproductcategories`, { tenantid }),
subCategories: (tenantid: number, categoryid: number) =>
api.list<ProductSubCategory>(`${WEB}/products/getproductsubcategories`, {
tenantid,
categoryid,
}),
/** Batch, idempotent. Send the whole selection in one call. */
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
/**
* Single product only — the backend parses one object, not an array.
*
* Answers with the created row, `productid` included. It used to echo back
* the request body, which meant `productid: 0` every time: the id is
* assigned by the database and nothing read it back. Callers that needed it
* — and pricing and stocking a product both do — had to re-read the
* catalogue and find their own row again by SKU.
*
* The payload arrives under `data` rather than `details`, which the client
* already handles.
*/
createProduct: (product: Partial<Product>) =>
api.post<Product>(`${WEB}/products/create`, product),
/** Array. Upserts on (tenantid, locationid, productid). */
createProductLocations: (rows: ProductLocationRequest[]) =>
api.post<unknown>(`${WEB}/products/createproductlocation`, rows),
/** Array. Appends to the stock ledger. */
createProductStock: (rows: ProductStockRequest[]) =>
api.post<unknown>(`${WEB}/products/createproductstock`, rows),
/**
* Price a product and release it to the shops.
*
* There is NO `locationid` — deliberately, on the backend's side. It reads
* the tenant's active outlets itself, because "a console that sent its own
* list could publish to a subset by omission"
* (`productPublishRepository.go:50`). One call sets this price at every
* branch and also writes `products.retailprice` and `taxpercent`.
*
* Refuses `price <= 0`.
*/
publish: (body: {
tenantid: number;
productid: number;
price: number;
taxpercent: number;
}) => api.post<unknown>(`${WEB}/products/publishproduct`, body),
/**
* Clear `publishedat`. Narrower than it sounds — the till and the customer
* app do not filter on this column, so this hides the product from the store
* catalogue view and nothing else. See `Product.publishedat`.
*/
unpublish: (body: { tenantid: number; productid: number }) =>
api.post<unknown>(`${WEB}/products/unpublishproduct`, body),
/**
* The tenant's real category list, synthesised from products in use.
*
* Not `getproductcategories` — that reads a master table missing rows for
* categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
*/
tenantCategories: (tenantid: number) =>
api.list<{ categoryid: number; categoryname: string }>(
`${WEB}/products/gettenantcategories`,
{ tenantid },
),
/**
* Exchanges category NAMES for this tenant's category ids, creating any that
* do not exist yet.
*
* POST because it writes: a sheet naming an aisle this shop has never stocked
* opens the aisle rather than failing. Keyed on the lowercased, trimmed name,
* so a caller looks up whatever casing its own sheet used.
*/
resolveCategories: (tenantid: number, names: string[]) =>
api.post<Record<string, number>>(`${WEB}/products/resolvecategories`, { tenantid, names }),
/**
* Re-files products into different aisles, in bulk.
*
* `categoryid` should be 2 on every row — the customer app FILTERS on it and
* anything else removes the product from its browse. `subcategoryid` is the
* one that decides the heading a shopper reads; 0 leaves whatever the product
* already has, so a caller that does not know the aisle cannot erase one.
*
* Scoped by tenant on the server as well as here — a productid is global, so
* a wrong id in the list would otherwise move another merchant's product.
*/
recategorise: (
tenantid: number,
updates: { productid: number; categoryid: number; subcategoryid?: number }[],
) => api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }),
/** Unlinks from the store. The product row and its order history survive. */
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
};
/* ────────────────────────────────────────────────────────────────────────────
The sheet-import dance
──────────────────────────────────────────────────────────────────────────── */
/** One validated row from the uploaded workbook. */
export interface SheetProductRow {
productname: string;
productsku: string;
/**
* The category NAME, from the ladder in `productCategory.ts`.
*
* The name is what the pipeline and the catalogue both speak; the id is
* per-tenant and is resolved from this at import time.
*/
category?: string;
categoryid: number;
subcategoryid: number;
retailprice: number;
productcost: number;
taxpercent: number;
quantity: number;
productunit?: string;
unitvalue?: string;
productbrand?: string;
productdesc?: string;
}
export interface SheetImportResult {
created: number;
linked: number;
stocked: number;
/** Rows the backend rejected, paired with the reason, so they can be retried. */
failures: { row: SheetProductRow; reason: string }[];
}
export interface SheetImportOptions {
tenantid: number;
locationid: number;
rows: SheetProductRow[];
onProgress?: (done: number, total: number) => void;
}
/**
* Imports a parsed sheet.
*
* NOT idempotent, and it cannot be made so from this side: nothing in the API
* dedupes on SKU, so uploading the same workbook twice creates the products
* twice. The importer UI is responsible for warning before a re-upload.
*
* Creates run sequentially rather than in parallel on purpose. There is no
* batch create, and firing 500 concurrent writes at a single-instance Go
* service to save a few seconds is a poor trade against a half-imported tenant.
*/
/**
* NO LONGER WIRED TO ANY SCREEN.
*
* The Upload sheet panel now hands the workbook to the ingest service
* (`api/ingest.ts`) instead of running this loop from the browser. Kept, not
* deleted, because the ingest contract is still unconfirmed and this is the
* known-working path back if that service turns out not to fit. Delete it once
* the ingest has run against real data and been signed off — a second import
* path that nobody calls is a thing that rots.
*/
export async function importSheetProducts(
options: SheetImportOptions,
): Promise<SheetImportResult> {
const { tenantid, locationid, rows, onProgress } = options;
const failures: SheetImportResult['failures'] = [];
const createdSkus: string[] = [];
/*
The aisles first, in one call, before a single product is created.
Every row carries a category NAME worked out by the ladder in
`productCategory.ts`. What the customer app groups by is not that category
but `products.subcategoryid` — one of ten platform rows under category 2 —
so the name is folded into an aisle and the aisle looked up by name here. See
`appAisle.ts` for the endpoint that is measured against.
A failure here is not fatal: the lookup falls back to the ids last read from
the platform, and a row that still cannot be placed is created with
subcategoryid 0, which the app lists under "Uncategorized".
*/
let aisleIds: ReadonlyMap<string, number>;
try {
aisleIds = aisleIdsFrom(await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY));
} catch {
aisleIds = aisleIdsFrom(undefined);
}
const subcategoryIdFor = (row: SheetProductRow): number =>
aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0;
const locationRows: ProductLocationRequest[] = [];
const stockRows: ProductStockRequest[] = [];
for (const [index, row] of rows.entries()) {
try {
const created = await productsApi.createProduct({
tenantid,
productname: row.productname,
productsku: row.productsku,
// ALWAYS 2 — the app filters on it and would drop anything else. The
// aisle a shopper reads is the subcategory.
categoryid: APP_BROWSE_CATEGORY,
subcategoryid: subcategoryIdFor(row),
retailprice: row.retailprice,
productcost: row.productcost,
taxpercent: row.taxpercent,
productunit: row.productunit,
unitvalue: row.unitvalue,
productbrand: row.productbrand,
productdesc: row.productdesc,
productstatus: 'Active',
});
/*
The id comes back from the create now.
This loop used to collect SKUs, then read the tenant's ENTIRE catalogue
back, build a SKU→product map and match its own rows against it, because
`POST /products/create` answered `productid: 0`. That is fixed on the
backend — the id is the database's and it is returned — so the second
read and the matching are both gone.
Worth saying what the old way actually cost, because it was not only the
extra request. Matching on SKU means matching on a column nothing
enforces: this importer creates duplicates on re-upload by design, and
the map kept the LAST row for a SKU, so a second upload sent the new
product's price and stock to whichever copy happened to win. A row whose
SKU was blank, or trimmed differently by the sheet, could not be found at
all and was reported as "Created, but could not be found again by SKU" —
a message about the console's own bookkeeping that a shop could do
nothing with.
A zero here would be worse than the old behaviour, so it is checked
rather than assumed: the product exists either way, and saying so is more
use than silently pricing product 0.
*/
if (!created?.productid) {
failures.push({
row,
reason: 'Created, but the server did not return its id — price and stock were not set',
});
onProgress?.(index + 1, rows.length);
continue;
}
createdSkus.push(row.productsku);
locationRows.push({
tenantid,
locationid,
productid: created.productid,
price: row.retailprice,
status: 'available',
});
stockRows.push({
tenantid,
locationid,
productid: created.productid,
quantity: row.quantity,
stocktype: 'in',
status: 'Active',
});
} catch (error) {
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
}
onProgress?.(index + 1, rows.length);
}
if (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
return {
created: createdSkus.length,
linked: locationRows.length,
stocked: stockRows.length,
failures,
};
}
/* ── Variants: one product, several sizes ────────────────────────────────── */
/**
* A size under a parent product.
*
* `variantproductid` is a REAL product row — its own price, its own stock, its
* own barcode — which is why a variant carries none of them. That is the whole
* design: "Cadbury 5 Star 18g" and "9.8g" stay two products the shop counts
* separately, and the app shows one card with a size picker.
*
* `variantname` is what the picker shows. It is free text rather than derived
* from the product name, because "Aachi Baby Fryums 500g" should read as "500g"
* in a row of three buttons, not repeat the brand three times.
*/
export interface ProductVariantLink {
variantid?: number;
tenantid: number;
/** The parent — the product the app shows. */
productid: number;
/** The product actually added to the basket for this size. */
variantproductid: number;
variantname: string;
varianttype?: string;
}
export const variantsApi = {
/**
* Group a product under a parent.
*
* The backend refuses a self-reference, a parent or child belonging to
* another tenant, and a duplicate link — so the console does not need to
* re-check any of that, only to show the reason.
*/
add: (link: ProductVariantLink) =>
api.post<ProductVariantLink>(`${WEB}/products/addproductvariant`, link),
/**
* Ungroup. The product itself is untouched — only the link goes.
*
* Keyed on `variantid`, the link's own id, not on the two product ids. The
* backend refuses anything else with "tenantid and variantid are both
* required", so the caller has to have read the link before it can drop it.
*/
remove: (params: { tenantid: number; variantid: number }) =>
api.del<unknown>(`${WEB}/products/removeproductvariant`, undefined, params),
};
/**
* The sizes under one product, as the customer app receives them.
*
* `/v1/mob`, not `/v1/web` — this endpoint exists only on the mobile group, and
* calling the web path 404s. Reading it from the console is deliberate: it is
* the only way to show a merchant exactly what a shopper will see, rather than
* a second rendering of the same links that can drift from it.
*
* The first entry is the PARENT ITSELF. A parent is one of its own sizes, so a
* picker of three has three entries, not a parent plus two.
*/
export const variantPreviewApi = {
forProduct: (params: { productid: number; tenantid: number; locationid: number }) =>
api.list<Product>(`${MOB}/products/getproductbyvariant`, params),
};

135
src/api/routing.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Real road geometry between two points.
*
* ── Why a third service ─────────────────────────────────────────────────────
*
* A straight line between two drops is not a route. On a map it cuts through
* blocks and across rivers, and its length is not the distance anybody rode —
* so a line drawn that way invites a measurement it cannot support. OSRM
* returns the actual road path, which is both honest and immediately readable
* as "they went round the one-way system".
*
* `router.project-osrm.org` is the project's own demo server. Verified reachable
* 2026-09-10 (200 in ~1.1 s for a Coimbatore leg). It is a courtesy service with
* no SLA and a fair-use policy, which shapes everything below: legs are cached,
* requests are capped per draw, and a failure is silent because a map that
* loses its road geometry is still a useful map.
*
* ── Failure is expected and must be cheap ───────────────────────────────────
*
* Every caller takes back a path or null, never an error. A null means "draw the
* straight line instead", which is what the map already did. Nothing about a
* dispatch board should break because a free routing server was busy.
*/
const OSRM_BASE = (
import.meta.env?.['VITE_OSRM_BASE'] ?? 'https://router.project-osrm.org'
)
.trim()
.replace(/\/+$/, '');
/** One leg's road geometry, as [lat, lng] pairs ready for a polyline. */
export type RoadPath = { lat: number; lng: number }[];
export interface Leg {
from: { lat: number; lng: number };
to: { lat: number; lng: number };
}
/**
* Legs already fetched, keyed on their rounded endpoints.
*
* Module-level and unbounded on purpose within a session: a dispatch board
* redraws constantly — every filter, every poll — and the same legs recur. Two
* hundred legs of geometry is a few hundred kilobytes, and re-fetching them
* from a courtesy server on each render is the behaviour that gets an IP
* blocked.
*/
const cache = new Map<string, RoadPath | null>();
/** Five decimal places is about a metre — finer than any two drops differ by. */
function keyOf(leg: Leg): string {
return `${leg.from.lat.toFixed(5)},${leg.from.lng.toFixed(5)};${leg.to.lat.toFixed(5)},${leg.to.lng.toFixed(5)}`;
}
/** A single leg's road path, or null when it cannot be had. */
async function fetchLeg(leg: Leg, signal?: AbortSignal): Promise<RoadPath | null> {
const url =
`${OSRM_BASE}/route/v1/driving/` +
`${leg.from.lng},${leg.from.lat};${leg.to.lng},${leg.to.lat}` +
`?overview=full&geometries=geojson`;
try {
const response = await fetch(url, { signal });
if (!response.ok) return null;
const body = (await response.json()) as {
routes?: { geometry?: { coordinates?: [number, number][] } }[];
};
const coordinates = body.routes?.[0]?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
// GeoJSON is [lng, lat]; leaflet wants lat first. Getting this backwards
// puts Coimbatore in the Arabian Sea, which is the classic symptom.
return coordinates.map(([lng, lat]) => ({ lat, lng }));
} catch {
// Includes the abort. A cancelled draw wants no path, same as a failed one.
return null;
}
}
/**
* How many legs one draw may ask for.
*
* A hundred-drop round is ninety-nine legs, and asking a courtesy server for
* all of them at once is how a shared IP gets rate-limited for everybody. Past
* this the map falls back to straight lines, which it can always draw.
*/
const MAX_LEGS_PER_DRAW = 60;
/** How many requests are in flight at once. Polite, and enough to feel instant. */
const CONCURRENCY = 4;
export const routingApi = {
/**
* Road geometry for a set of legs.
*
* Returns a map keyed the same way the caller can look up — `keyFor(leg)` —
* holding a path or null per leg. Cached legs cost nothing; uncached ones are
* fetched a few at a time.
*
* Never throws. A leg that could not be routed is absent from the result and
* the caller draws its straight line, which is what it did before.
*/
roads: async (legs: readonly Leg[], signal?: AbortSignal): Promise<Map<string, RoadPath>> => {
const out = new Map<string, RoadPath>();
const wanted: Leg[] = [];
for (const leg of legs) {
const key = keyOf(leg);
if (cache.has(key)) {
const hit = cache.get(key);
if (hit) out.set(key, hit);
} else if (wanted.length < MAX_LEGS_PER_DRAW) {
wanted.push(leg);
}
}
for (let i = 0; i < wanted.length; i += CONCURRENCY) {
if (signal?.aborted) break;
const batch = wanted.slice(i, i + CONCURRENCY);
const paths = await Promise.all(batch.map((leg) => fetchLeg(leg, signal)));
batch.forEach((leg, index) => {
const key = keyOf(leg);
const path = paths[index] ?? null;
// Null is cached too: a leg the router cannot do will not start working
// if it is asked sixty more times this session.
cache.set(key, path);
if (path) out.set(key, path);
});
}
return out;
},
/** The key a leg's path is stored under, for callers reading the result. */
keyFor: keyOf,
};

193
src/api/stock.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* Stock requests and stock movement — the Store Admin's approval surface.
*
* Read `store-admin-backend-gap.md` §2.2 before extending this file. The
* approval workflow the spec describes does not exist in Fiesta: the request
* table has no reason, requester, approved quantity, approver or remarks, and
* `UpdateStockRequest(requestID, status)` takes a bare string. Exactly one
* value does anything — "Received" — and it posts a stock movement for the FULL
* requested quantity, guarded against double-receiving.
*
* So "approve for a different quantity" is not implemented here because it
* cannot be implemented here. It is a backend change, not a frontend one.
*/
import { api, WEB } from './client';
import type { StockRequest, StockStatementRow } from './types';
/**
* The status values the backend actually distinguishes.
*
* `Received` is the only one with behaviour. The others are stored verbatim and
* read back, which is enough to drive a queue but is not a state machine — the
* backend will accept any string at all.
*/
/**
* The ladder a request climbs.
*
* `Approved` sits between asking and arriving, and adding it is the point:
* approving used to put the stock on the shelf immediately, so the count said
* the goods were there from the moment the admin agreed to send them — which is
* days before they arrive, and the branch sells against a shelf that is empty.
*
* Only `Received` moves the ledger. Fiesta keys the stock write on that exact
* word, so `Approved` is a status and nothing else.
*/
export const STOCK_REQUEST_STATUS = {
pending: 'Pending',
approved: 'Approved',
received: 'Received',
rejected: 'Rejected',
} as const;
export type StockRequestStatus = (typeof STOCK_REQUEST_STATUS)[keyof typeof STOCK_REQUEST_STATUS];
export interface StockRequestQuery {
tenantid: number;
/** Omit for every branch. */
locationid?: number;
status?: string;
date?: string;
pageno?: number;
pagesize?: number;
}
/**
* What a batch actually did.
*
* Both lists are always read: a batch that half-worked is the case worth
* reporting, and the failures name the row so somebody can go and look.
*/
export interface StockBatchOutcome {
updated?: number[];
created?: unknown[];
failed?: { requestid?: number; productid?: number; reason: string }[];
}
export interface CreateStockRequest {
tenantid: number;
locationid: number;
productid: number;
qty: number;
/** Carried so the admin's queue can name the branch without a second read. */
locationname?: string;
productname?: string;
}
export const stockApi = {
/**
* A branch asks its admin for stock.
*
* The only write a Store user has against inventory, and deliberately so:
* nothing here moves the ledger. `status` is always Pending — the backend
* defaults to it when blank, but sending it makes the intent explicit rather
* than relying on a default that a later release could change.
*
* There is no reason field, no requester and no wanted-by date in
* `stockrequests`, so the request carries a product and a quantity and
* nothing else. Do not invent the rest in the UI.
*/
create: (body: CreateStockRequest) =>
api.post<StockRequest>(`${WEB}/products/createstockrequest`, {
...body,
status: STOCK_REQUEST_STATUS.pending,
}),
requests: (query: StockRequestQuery) =>
api.list<StockRequest>(`${WEB}/products/getstockrequests`, {
tenantid: query.tenantid,
locationid: query.locationid,
status: query.status,
date: query.date,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 50,
}),
/**
* Agree to send the stock. Nothing reaches the shelf yet.
*
* The shelf is written when the branch confirms the goods ARRIVED, not when
* the admin agrees to send them — see `confirmArrival`.
*/
approve: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.approved,
}),
/**
* The goods turned up. THIS is what adds `request.qty` to the branch's stock.
*
* There is no way to receive a different amount: the service reads the
* quantity off the request row, not off this call. A short delivery has to be
* corrected on the stock ledger afterwards.
*/
confirmArrival: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.received,
}),
/**
* Several requests at once.
*
* One call rather than a loop of them, because approving MOVES STOCK: a loop
* that dies halfway leaves some deliveries received and some not, with nothing
* to say which. The backend applies each id separately and reports both lists,
* so a partial outcome is a fact the screen can show rather than a guess.
*
* The same status for the whole batch, never a mix. "Approve these" and
* "reject these" are two decisions, and one call that could do both is how a
* mis-click approves what it meant to refuse.
*/
decideMany: (requestids: number[], status: StockRequestStatus) =>
api.put<StockBatchOutcome>(`${WEB}/products/updatestockrequest`, { requestids, status }),
approveMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.approved),
confirmArrivalMany: (requestids: number[]) =>
stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.received),
rejectMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.rejected),
/**
* A branch asks for several products in one go.
*
* Restocking after a delivery is one errand, not twenty. Sending it as twenty
* calls is slow, and a dropped connection leaves a half-made request list that
* nobody can tell apart from a deliberate one.
*/
createMany: (rows: CreateStockRequest[]) =>
api.post<StockBatchOutcome>(`${WEB}/products/createstockrequest`,
rows.map((row) => ({ ...row, status: STOCK_REQUEST_STATUS.pending }))),
/** Reject — a status write and nothing else. No stock moves, no reason stored. */
reject: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.rejected,
}),
/**
* Stock movement for one branch.
*
* Opening, credit, debit, closing — and that is the whole vocabulary.
* `productstocks.stocktype` is `in`/`out`, so a sale, a transfer and a
* correction are indistinguishable once written. The spec's seven movement
* types (§3.8) cannot be sourced; see gap §2.5.
*/
statement: (params: {
tenantid: number;
locationid: number;
subcategoryid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) =>
api.list<StockStatementRow>(`${WEB}/products/getstockstatement`, {
tenantid: params.tenantid,
locationid: params.locationid,
subcategoryid: params.subcategoryid,
keyword: params.keyword,
pageno: params.pageno ?? 0,
pagesize: params.pagesize ?? 100,
}),
};

112
src/api/telemetry.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Where a rider is right now, and how their phone is doing.
*
* ── This is a different backend, and that is the whole point ────────────────
*
* `jupiter.nearle.app` — the platform's older API, which the xpress console
* runs against. Fiesta has no equivalent: `getriderperiodiclogs` does not exist
* there under any prefix, and `partners/getriderlogs`, the closest-looking
* Fiesta endpoint, is a heartbeat log that repeats one fixed coordinate per
* rider all day (see `riderShifts`). So this is the ONLY live rider position
* on the platform, and it was missed once already by checking Fiesta alone.
*
* Verified 2026-09-10: rider 852 polled thirty seconds apart moved about 140 m,
* with battery, connection and accuracy all changing. Genuinely live.
*
* Same database as Fiesta underneath — `getallriders` on jupiter and
* `getriders` on Fiesta return identical rosters and identical on-duty state —
* so a userid from one is a userid in the other.
*
* ── One rider per call ──────────────────────────────────────────────────────
*
* There is no fleet form: `?userid=N` answers for that rider, and omitting it
* answers for whichever rider reported most recently, which is not useful. A
* fleet view therefore fans out, which is fine at the sizes involved — a
* merchant's round is a handful of riders.
*
* ── Freshness is the caller's problem, and must be shown ────────────────────
*
* The endpoint always answers, and it answers with the LAST known fix however
* old. Riders 883, 897 and 1111 come back with positions from 5, 3 and 12 days
* ago and nothing in the payload flags them as stale. A map that draws those
* next to a live one is lying, so `logdate` is parsed here and every consumer
* is handed an age rather than a bare position.
*/
const JUPITER_BASE = (
import.meta.env?.['VITE_JUPITER_BASE'] ?? 'https://jupiter.nearle.app'
)
.trim()
.replace(/\/+$/, '');
/** One rider's live snapshot, exactly as jupiter sends it. */
export interface RiderSnapshot {
userid: number;
username?: string;
latitude?: string;
longitude?: string;
/** `2026-09-10 11:25:11` — server local time, no zone. */
logdate?: string;
/** `"95%"`, with the sign. */
battery?: string;
/** `mobile`, `wifi`, `none`. */
connection?: string;
/** Metres of GPS uncertainty, as a string. 100.0 is a poor fix. */
accuracy?: string;
/** Km/h and degrees, both as strings. */
speed?: string;
heading?: string;
/** `idle`, `active`, and whatever else the app decides to send. */
status?: string;
/** The order they are on, when they are on one. */
orderid?: string;
is_charging?: boolean;
is_background?: boolean;
/** `enabled` / `disabled` — a disabled one explains a stale position. */
location_service?: string;
}
export class TelemetryError extends Error {
constructor(message: string) {
super(message);
this.name = 'TelemetryError';
}
}
export const telemetryApi = {
/**
* One rider's latest reported position and phone state.
*
* Never throws for "this rider has never reported" — that answers 200 with an
* empty-ish body, and the caller wants to draw the rider as unreachable
* rather than show an error. It throws only when jupiter itself cannot be
* reached, which is a different thing and worth saying out loud: jupiter can
* be down while Fiesta is fine, and vice versa.
*/
rider: async (userid: number, signal?: AbortSignal): Promise<RiderSnapshot | null> => {
let response: Response;
try {
response = await fetch(
`${JUPITER_BASE}/live/api/v1/utils/getriderperiodiclogs?userid=${userid}`,
{ headers: { Accept: 'application/json' }, signal },
);
} catch (error) {
if ((error as Error)?.name === 'AbortError') throw error;
throw new TelemetryError(
'Could not reach the live rider service. It is a separate backend from the rest of the console, so everything else keeps working.',
);
}
if (!response.ok) {
throw new TelemetryError(`The live rider service answered ${response.status}.`);
}
const payload = (await response.json().catch(() => null)) as
| { data?: RiderSnapshot }
| null;
const data = payload?.data;
// A rider who has never opened the app comes back without coordinates.
// Null rather than an empty object, so "no fix" is one check everywhere.
return data && (data.latitude || data.longitude) ? data : null;
},
};

319
src/api/tenants.ts Normal file
View File

@@ -0,0 +1,319 @@
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
import { api, WEB } from './client';
import type { AppLocation } from './deliveries';
import type { TenantInfo, TenantLocation } from './types';
/** Everything the tenant-onboarding form collects. */
export interface CreateTenantRequest {
tenantname: string;
companyname: string;
primarycontact: string;
primaryemail: string;
/**
* Who runs the shop — `tenants.firstname`.
*
* Asked here because here is the only moment it can be asked. The primary
* branch and the merchant's own admin login are both created inside
* `CreateTenantUser`'s transaction, and the login is copied from the tenant
* row — so a name given now names the business AND the account that will
* sign in. Left out, both are blank, which is what every merchant on the
* platform currently has: the store profile prints "—" for Store admin and
* the account carries no person's name at all.
*
* One field, not two: `tenants` has a `firstname` column and no
* `lastname`, so this is the whole name.
*/
firstname?: string;
locationname: string;
categoryid: number;
subcategoryid?: number;
address: string;
suburb?: string;
city: string;
state: string;
postcode: string;
latitude?: string;
longitude?: string;
moduleid?: number;
/** The city this merchant trades in. `app_location`, not a branch. */
applocationid?: number;
status?: string;
}
/** Everything the branch-onboarding form collects. */
export interface CreateBranchRequest {
tenantid: number;
/**
* The delivery region, inherited from the tenant's existing outlets.
*
* `tenantlocations.applocationid` has no column default, so a create that
* omits it stores 0 — and `resolveOfflineLocationContext` in
* `orderRepository.go` calls this column "authoritative", with no fallback
* anywhere for a zero. It is also copied straight onto the login the backend
* spawns for the branch, so the outlet AND the person running it both end up
* in no region at all.
*
* Measured 2026-09-15: 43 of 75 live branches carry 0. Regions are
* 1 = Coimbatore, 2 = Madurai, 23 = Nagercoil.
*/
applocationid?: number;
/**
* Also inherited, and also without a column default.
*
* `orderRepository.go` documents the consequence in its own comment —
* "tenantlocations carries 0 for moduleid/partnerid at outlets whose live
* orders nonetheless use non-zero values" — and works around it by copying
* the scaffolding off the most recent real order at that outlet. A branch
* commissioned five minutes ago has no such order, so the workaround has
* nothing to copy and the joins are left to resolve against a zero.
*/
moduleid?: number;
locationname: string;
email?: string;
contactno?: string;
address: string;
suburb?: string;
city: string;
state: string;
postcode: string;
latitude?: string;
longitude?: string;
opentime?: string;
closetime?: string;
deliveryradius?: number;
deliverymins?: number;
status?: string;
/**
* Who will run this outlet — an existing person, when one has been hired
* already.
*
* Omitted, the backend spawns a login named after the SHOP, on the shop's
* email address, one per outlet. That was the only option, and it is why two
* people at a counter shared a credential and nothing recorded which of them
* did anything.
*
* A branch must still arrive with SOMEBODY: name a person here, or give an
* `email` to spawn one from. The backend refuses a branch with neither,
* because an outlet nobody can sign in to is a dead end that shows up in
* every list and is noticed by whoever is standing in the shop.
*/
operatorid?: number;
}
export interface TenantListQuery {
pageno?: number;
pagesize?: number;
/** `Active` / `InActive`. Omitted, the backend returns every state. */
status?: string;
applocationid?: number;
tenanttype?: string;
keyword?: string;
}
export const tenantsApi = {
/**
* Every tenant on the platform. Deliberately unscoped — this is the
* Nearle Admin's list, and the backend treats it as the platform-operator
* endpoint rather than a tenant-scoped one.
*/
listAll: (query: TenantListQuery = {}) =>
api.list<TenantInfo>(`${WEB}/tenants/getalltenants`, {
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 100,
status: query.status,
applocationid: query.applocationid,
tenanttype: query.tenanttype,
keyword: query.keyword,
}),
/**
* Tenants by approval state — the only way to see the ones awaiting it.
*
* `status=pending` is not a status at all: the handler branches on the word
* and queries `approved = 0` instead (`tenantRepository.go:45-77`). Anything
* else means `approved = 1 AND status = ?`. So an unapproved merchant is
* invisible to every other endpoint, including `getalltenants`.
*
* Nothing can approve one over HTTP. `approved` is writable only at creation,
* so this list is a queue to work from, not one to act on.
*/
byApproval: (status: 'pending' | 'Active' | 'InActive', keyword?: string) =>
api.list<TenantInfo>(`${WEB}/tenants/search`, { status, keyword }),
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
locations: (tenantid: number) =>
api.list<TenantLocation>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
search: (keyword: string) =>
api.list<TenantInfo>(`${WEB}/tenants/searchbykeyword`, { keyword }),
/**
* Provisions the enterprise, its first outlet, and the primary Administrator
* account — one transaction writing `tenants`, `ordersequences`, `app_users`
* (roleid 1, configid forced to 1), `customers`, `customerlocations` and
* `tenantcustomers`.
*
* `createtenantuser`, NOT `createtenantlocation`. The latter takes a
* `Tenantlocations` and writes a BRANCH under a tenant that already exists —
* pointing the merchant form at it created an outlet and no merchant.
*
* The primary outlet is NESTED. The backend reads it off
* `Tenants.Tenantlocations` and creates it in the same transaction, so a
* tenant can never exist without somewhere to trade from.
*/
createTenant: (body: CreateTenantRequest) =>
api.post<TenantInfo>(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
/**
* Commissions a branch, and gives it somebody to run it.
*
* Pass `operatorid` to place a person you have already hired. Without it the
* backend spawns a login named after the shop, as it always did — kept so
* nothing existing changes, but the named person is the better path.
*
* `createtenantlocation`, not `createlocation`: only this one returns the
* created row, and the new `locationid` is what a QR code and every
* follow-up write need. `createlocation` answers 201 with a message and no
* `details` at all.
*/
createBranch: (body: CreateBranchRequest) =>
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
/**
* A merchant editing their own business record.
*
* The first write path `tenants` has ever had. Before it, everything about a
* shop — its name, its photograph, its licence, how to reach it — was set
* once at onboarding by a Nearle Admin and could never be changed by anyone.
*
* Only merchant-owned columns are written; the backend keeps the allowlist
* and ignores the rest, so `approved`, `status`, `partnerid` and the billing
* fields cannot be set from here even if a caller sends them. Anything
* omitted is left alone rather than blanked.
*/
updateProfile: (body: { tenantid: number } & Partial<TenantInfo>) =>
api.put<unknown>(`${WEB}/tenants/updatetenant`, body),
/**
* Which delivery partner supplies this merchant's riders.
*
* Its own endpoint, not a field on `updateProfile`: `partnerid` is kept out
* of the merchant-editable allowlist on purpose, because a merchant who could
* set it would move themselves under another partner's riders and billing.
*
* `partnerid: 0` is a real instruction — it means "this merchant uses their
* own riders" — and the server reads it as sent rather than as absent.
*/
assignPartner: (tenantid: number, partnerid: number) =>
api.put<unknown>(`${WEB}/tenants/assignpartner`, { tenantid, partnerid }),
/**
* One business, by id — how a store login reads its own record.
*
* Not `listAll`. That is `getalltenants`, paginated over 262 merchants, so a
* shop on page two was simply absent and a profile screen built on it would
* show nothing for no visible reason.
*/
byId: (tenantid: number) => api.get<TenantInfo>(`${WEB}/tenants/gettenantinfo`, { tenantid }),
/**
* Somebody editing their own name, mobile or email.
*
* Not `users/update`. That one writes whatever struct it is handed and checks
* only the userid — no tenant, no guard on role or branch — so a self-service
* form built on it would let a branch user promote themselves or move shop.
* This is scoped to the caller's own account AND business, and writes
* identity fields only.
*/
updateOwnProfile: (body: {
userid: number;
tenantid: number;
firstname?: string;
lastname?: string;
contactno?: string;
email?: string;
}) => api.put<unknown>(`${WEB}/tenants/updateownprofile`, body),
};
/**
* The merchant form, in the shape `models.Tenants` expects.
*
* `configid` and `applocationid` are sent because the account this call spawns
* is looked up by `configid` at every sign-in, and `applocationid` is the city
* the tenant trades in. `approved: 1` and `status: 'Active'` are set here
* because they can only ever be set here — there is no update or approve
* endpoint, so a tenant created unapproved stays unapproved forever.
*/
function toTenantBody(form: CreateTenantRequest): Record<string, unknown> {
return {
tenantname: form.tenantname,
companyname: form.companyname,
primaryemail: form.primaryemail,
primarycontact: form.primarycontact,
// Written to `tenants.firstname` and copied onto the admin login the same
// transaction creates — see the note on the field.
firstname: (form.firstname ?? '').trim(),
categoryid: form.categoryid,
subcategoryid: form.subcategoryid ?? 0,
address: form.address,
suburb: form.suburb ?? '',
city: form.city,
state: form.state,
postcode: form.postcode,
latitude: form.latitude ?? '',
longitude: form.longitude ?? '',
configid: 1,
moduleid: form.moduleid ?? 2,
applocationid: form.applocationid ?? 1,
approved: 1,
status: form.status ?? 'Active',
// The primary outlet, created in the same transaction. Its address
// defaults to the tenant's — a merchant's first shop is at the address
// they just typed far more often than not, and it can be edited after.
tenantlocations: {
locationname: form.locationname,
email: form.primaryemail,
contactno: form.primarycontact,
address: form.address,
suburb: form.suburb ?? '',
city: form.city,
state: form.state,
postcode: form.postcode,
latitude: form.latitude ?? '',
longitude: form.longitude ?? '',
applocationid: form.applocationid ?? 1,
status: 'Active',
},
};
}
/** One row of the `app_category` master — the business categories a tenant picks from. */
export interface AppCategory {
categoryid: number;
categoryname: string;
}
export const utilsApi = {
/**
* The business-category master.
*
* Read rather than hardcoded. The old console typed four values into the
* form and never called this, which means a category added to the master is
* invisible to onboarding until someone edits the frontend.
*/
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
/**
* The delivery regions — Coimbatore, Madurai, Nagercoil today.
*
* `applocationid` is REQUIRED by the handler and 0 is how you ask for all of
* them; omitting it answers 400 "Invalid applocationid", which reads as a
* broken request rather than a missing default.
*/
appLocations: (applocationid = 0) =>
api.list<AppLocation>(`${WEB}/utils/getapplocations`, { applocationid }),
};

1003
src/api/types.ts Normal file

File diff suppressed because it is too large Load Diff

100
src/api/uploads.test.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* The receipt, and the two things it has to get right.
*
* A receipt exists because the ingest service's batch id is the only credential
* for reading a result back, it is handed out once, and an unreviewed drop is
* deleted after seven days. So the tests that matter are about not losing that
* window, and about the label their admin reads when deciding whether to
* approve the file.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildSender, daysUntilExpiry, DROP_RETENTION_DAYS, type UploadReceipt } from './uploads';
function receipt(overrides: Partial<UploadReceipt> = {}): UploadReceipt {
return {
uploadid: 1,
tenantid: 1141,
locationid: 1180,
categoryid: 2,
batchid: '49a82536866a483a9189954d3c749243',
runid: '',
filename: 'kmart-opening.xlsx',
sender: 'Kmart · Peelamedu · abhishek',
uploadedby: 1475,
uploadedname: 'abhishek',
rowcount: 20,
laststatus: 'pending',
inserted: 0,
backfilled: 0,
skipped: 0,
rejected: 0,
shelvedcount: 0,
skippedcount: 0,
shelvedat: null,
created: new Date().toISOString(),
updated: new Date().toISOString(),
...overrides,
};
}
const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString();
test('a drop uploaded today has the full window left', () => {
assert.equal(daysUntilExpiry(receipt()), DROP_RETENTION_DAYS);
});
test('the window closes as the drop sits unreviewed', () => {
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(5) })), 2);
});
// Never negative. A drop past the deadline is gone, and "-3 days left" would
// read as a countdown that is still running.
test('an expired drop reports zero, not a negative', () => {
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(30) })), 0);
});
/*
Retention applies to a drop nobody acted on. Once an admin releases it the run
is the record and the drop's own expiry is irrelevant — showing a countdown on
a released upload would push someone to chase a deadline that has already been
met.
*/
test('a released drop has no expiry to report', () => {
assert.equal(daysUntilExpiry(receipt({ runid: '8dcef8a2ad94', laststatus: 'retired' })), null);
assert.equal(daysUntilExpiry(receipt({ laststatus: 'done' })), null);
});
test('a receipt with an unreadable date reports nothing rather than guessing', () => {
assert.equal(daysUntilExpiry(receipt({ created: 'not a date' })), null);
});
/*
The sender label. Free text on their side, capped at 60 characters, and shown to
the admin who decides whether to run the file — so it has to identify the shop,
not the console.
*/
test('the sender names the merchant, the branch and the person', () => {
assert.equal(
buildSender({ tenantname: 'Kmart', locationname: 'Peelamedu', username: 'abhishek' }),
'Kmart · Peelamedu · abhishek',
);
});
// The tenant name is truncated, not the whole label. Cutting the tail would
// drop the branch and the person — the two parts that say WHICH shelf and WHO —
// and leave only a long restaurant name that identifies neither.
test('a long merchant name is trimmed so the branch and person survive', () => {
const sender = buildSender({
tenantname: 'Ninhao The New Age Chinese Restaurant',
locationname: 'Race Course',
username: 'abhishek',
});
assert.ok(sender.length <= 60, `sender was ${sender.length} chars: ${sender}`);
assert.ok(sender.includes('Race Course'), sender);
assert.ok(sender.includes('abhishek'), sender);
});
test('a sender with nothing to say still labels the console', () => {
assert.equal(buildSender({}), 'nearle-console');
});

220
src/api/uploads.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Receipts for spreadsheets sent to the catalogue ingest service.
*
* This is OUR record, in Fiesta — not the ingest service's. The two are read
* together and neither is redundant:
*
* - **Fiesta** knows the batch id, which shop the sheet was for, who sent it,
* and whether the products reached that shop's shelf. None of which the
* ingest service has any concept of — it writes the shared global
* catalogue and has no tenant and no branch.
* - **The ingest service** knows what became of the drop, and is the only
* authority on that.
*
* Fiesta's status columns are a CACHE of the second, written by whichever
* browser last polled. They exist so a list of twenty receipts renders without
* twenty network calls to a host that spends minutes per batch; the live read
* is what any single receipt is judged by.
*
* ── Why the receipt has to exist at all ──────────────────────────────────────
*
* Three facts from the ingest service's own documentation, and any one of them
* would be enough:
*
* 1. **The batch id is the credential.** `GET /api/uploads/catalog/{id}` is
* anonymous by design — holding the id is the proof of having sent the
* drop. Handed out once, to one browser. Lose it and the result is
* unreadable by anyone, including whoever uploaded the file.
* 2. **An unreviewed drop is deleted after seven days.** Nothing runs on
* arrival; a drop waits for an admin to press Start. If nobody does, the
* evidence expires.
* 3. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
* the credential that sent them, production has no API keys configured at
* all, and the only account that could read it is a superuser over their
* entire application.
*/
import { api, WEB } from './client';
/**
* One upload, as Fiesta stores it.
*
* The two count groups are deliberately not merged. `inserted` and its
* neighbours are the ingest service's — products in the GLOBAL catalogue, which
* every merchant shares and which therefore carries no price and no stock.
* `shelvedcount` is ours: priced, on a branch's shelf, with opening stock
* recorded. A product can be in the first and not the second, and reporting it
* as "added" would tell a shopkeeper they can sell something nobody can buy.
*/
export interface UploadReceipt {
uploadid: number;
tenantid: number;
locationid: number;
categoryid: number;
/** The drop id. The only field here that cannot be reconstructed. */
batchid: string;
/** The run an admin released the drop into, once they have. */
runid: string;
filename: string;
/** The label their review inbox shows. */
sender: string;
uploadedby: number;
uploadedname: string;
/** Rows we parsed before sending — independent of anything the service says. */
rowcount: number;
/**
* The parsed sheet as JSON, or empty when it was never stored.
*
* Empty is the interesting case: it means the prices and opening stock are
* gone, and the only way to shelve this upload is for somebody to hand the
* file over again. Receipts written before this column existed are all in
* that state.
*/
sheetrows?: string;
/* ── Cached from the ingest service ──────────────────────────────────── */
laststatus: string;
inserted: number;
backfilled: number;
skipped: number;
rejected: number;
/* ── Ours ────────────────────────────────────────────────────────────── */
shelvedcount: number;
skippedcount: number;
shelvedat: string | null;
created: string;
updated: string;
/** Joined for display; a receipt outlives the page that made it. */
tenantname?: string;
locationname?: string;
}
export interface RecordUploadBody {
/**
* The parsed sheet as JSON — SKU, price and opening stock per row.
*
* Stored so the shelving step can run later, from the Uploads page, without
* the original file or the tab that sent it. The prices and opening stock
* exist nowhere else: the ingest service's catalogue is shared by every
* merchant and carries neither.
*/
sheetrows?: string;
tenantid: number;
locationid: number;
categoryid: number;
batchid: string;
filename: string;
sender: string;
uploadedby: number;
uploadedname: string;
rowcount: number;
laststatus?: string;
}
export interface UploadQuery {
/** 0 or omitted means every tenant — how a Nearle Admin sees the platform. */
tenantid?: number;
locationid?: number;
pageno?: number;
pagesize?: number;
}
/**
* How long the ingest service keeps a drop nobody has acted on.
*
* `BATCH_RETENTION_DAYS` on their side. Worth showing rather than discovering:
* a drop that expires unreviewed leaves no trace at either end, and the only
* remedy — asking an admin to release it — has to happen before the deadline.
*/
export const DROP_RETENTION_DAYS = 7;
/** Days left before an unreviewed drop is deleted; null once it has run. */
export function daysUntilExpiry(receipt: UploadReceipt): number | null {
// Only a drop still sitting in the review inbox expires. Once released, the
// run is the record and retention no longer applies to it.
if (receipt.runid || receipt.laststatus !== 'pending') return null;
const created = Date.parse(receipt.created);
if (Number.isNaN(created)) return null;
const elapsedDays = (Date.now() - created) / 86_400_000;
return Math.max(0, Math.ceil(DROP_RETENTION_DAYS - elapsedDays));
}
/**
* The label the ingest service's admin sees in their review inbox.
*
* Their field is free text capped at 60 characters, and until now every upload
* from this console arrived as the same constant — so an admin deciding what to
* approve could not tell one merchant's sheet from another's.
*
* Safe to make specific precisely because we never send a credential. Their
* ownership filter matches `sender` EXACTLY, and a run an admin assembles from
* several drops carries a joined list ("alice, bob") — so a credentialed caller
* gets a 404 on a run containing their own file. We read anonymously, holding
* the id, which is what their documentation tells integrators to do.
*
* The tenant name is truncated rather than the whole label, so the branch and
* the person survive: "Ninhao The New Age Chinese Restaurant" is 36 characters
* on its own and would otherwise push everything identifying off the end.
*/
export function buildSender(parts: {
tenantname?: string;
locationname?: string;
username?: string;
}): string {
const tenant = (parts.tenantname ?? '').trim().slice(0, 24);
const label = [tenant, (parts.locationname ?? '').trim(), (parts.username ?? '').trim()]
.filter(Boolean)
.join(' · ');
return (label || 'nearle-console').slice(0, 60);
}
export const uploadsApi = {
/**
* Store the receipt. Called the instant the drop is accepted, before polling.
*
* That timing is the whole point: it is the one moment the batch id is
* guaranteed to exist and guaranteed not to have been lost to a closed tab.
* Idempotent on `batchid` server-side, so a retry or a second tab is safe.
*/
record: (body: RecordUploadBody) => api.post<UploadReceipt>(`${WEB}/uploads/record`, body),
list: (query: UploadQuery = {}) =>
api.list<UploadReceipt>(`${WEB}/uploads/list`, {
tenantid: query.tenantid ?? 0,
locationid: query.locationid ?? 0,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 50,
}),
/** Cache what the ingest service last reported, so the next reader need not wait. */
updateStatus: (body: {
batchid: string;
laststatus: string;
runid?: string;
inserted?: number;
backfilled?: number;
skipped?: number;
rejected?: number;
}) => api.put<unknown>(`${WEB}/uploads/update`, body),
/**
* Supply the prices and opening stock for a receipt that has none.
*
* The rescue path. A receipt filed before the sheet was stored cannot be
* shelved from anywhere, because the ingest service holds a catalogue every
* merchant shares and it carries neither figure — so the file has to come
* back. Sent as JSON so the shelving can then run without it again.
*/
attachSheet: (body: { batchid: string; sheetrows: string }) =>
api.put<unknown>(`/uploads/sheet`, body),
/** Record the other half: priced, shelved and stocked at a branch. */
markShelved: (body: { batchid: string; shelved: number; skipped: number }) =>
api.put<unknown>(`${WEB}/uploads/shelved`, body),
};