/** * 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; /** 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; } 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; 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; 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 { 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(response); } /** One poll. */ export async function fetchBatch(batchId: string, signal?: AbortSignal): Promise { 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(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 { 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' ); } /** * Polls until the batch settles. * * Every two seconds. The pipeline's own stages take far longer than that, and a * person is watching a progress bar — a slower cadence buys nothing but a * screen that looks stuck. `onTick` fires on each reading so the caller can * render the stage name and row counts as they move. */ export async function pollBatch( batchId: string, onTick: (batch: IngestBatch) => void, signal?: AbortSignal, ): Promise { 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); // Stops on a review hold and on a dismissal as well as on a result. Waiting // for an admin is not progress, a declined drop will never produce one, and // a browser tab cannot outlast either — the drop id is what the operator // comes back with. if (isSettled(batch) || isAwaitingReview(batch) || isDismissed(batch)) return batch; await new Promise((resolve) => setTimeout(resolve, 2000)); } } /* ── Reading a response ───────────────────────────────────────────────────── */ async function readResponse(response: Response): Promise { 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 }; }