agent conformation
This commit is contained in:
191
src/api/uploads.ts
Normal file
191
src/api/uploads.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/* ── 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 {
|
||||
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),
|
||||
|
||||
/** 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),
|
||||
};
|
||||
Reference in New Issue
Block a user