/** * 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(`${WEB}/uploads/record`, body), list: (query: UploadQuery = {}) => api.list(`${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(`${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(`/uploads/sheet`, body), /** Record the other half: priced, shelved and stocked at a branch. */ markShelved: (body: { batchid: string; shelved: number; skipped: number }) => api.put(`${WEB}/uploads/shelved`, body), };