/** * @license * SPDX-License-Identifier: Apache-2.0 */ /** * Fiesta REST client — the merchant-facing `live/api/v1/web/*` surface served by * https://fiesta.nearle.app (documented at developer.nearledaily.com under the * REST tab). This is the operational backend: order/delivery/location summaries, * the deliveries board, riders, stock statements, and customers. * * Requests go directly to `https://fiesta.nearle.app/*` — Fiesta is CORS-enabled * and needs no auth header for these read endpoints, so no dev proxy is required. * * This sits alongside `./api` (the Hasura/workolik REST surface the dashboard * uses). Components should call the TanStack hooks in `./fiestaQueries`, not * these functions directly. */ const FIESTA_BASE = import.meta.env.VITE_FIESTA_URL || 'https://fiesta.nearle.app/live/api/v1/web'; const FIESTA_MOB_BASE = import.meta.env.VITE_FIESTA_MOB_URL || 'https://fiesta.nearle.app/live/api/v1/mob'; /** Tenant / location scope shared by the merchant console. */ export const FIESTA_TENANT_ID = 1135; export const FIESTA_APPLOCATION_ID = 1; /** Primary outlet for this tenant — the one carrying live orders/stock. */ export const FIESTA_PRIMARY_LOCATION_ID = 1170; export type Row = Record; type QueryParams = Record; /** * The exact payload the nearledaily consumer app expects when its in-app scanner * reads a store QR: a JSON object `{"tenantid":N,"locationid":N}`. The app parses * this and resolves the outlet from it. * * IMPORTANT: it must be this JSON shape, NOT a URL — the app rejects a URL with * "invalid QR code content". Keep it to exactly these two keys to match the app's * schema; extra keys risk strict-schema rejection on the app side. */ export function buildStoreQrPayload(opts: { tenantid: number; locationid: number }): string { return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid }); } async function fiestaGet(endpoint: string, params: QueryParams = {}): Promise { const qs = new URLSearchParams(); Object.entries(params).forEach(([k, v]) => { // Fiesta requires some params to be present-but-empty (e.g. keyword=), so we // keep empty strings and only drop undefined/null. if (v !== undefined && v !== null) qs.append(k, String(v)); }); const query = qs.toString(); const res = await fetch(`${FIESTA_BASE}/${endpoint}${query ? `?${query}` : ''}`, { headers: { Accept: 'application/json' }, }); if (!res.ok) { throw new Error(`Fiesta ${endpoint} failed: ${res.status} ${res.statusText}`); } return res.json() as Promise; } async function fiestaSend( endpoint: string, method: 'POST' | 'PUT' | 'DELETE', body?: unknown, ): Promise { const res = await fetch(`${FIESTA_BASE}/${endpoint}`, { method, headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const json = (await res.json().catch(() => null)) as | { message?: string; status?: boolean } | null; if (!res.ok || (json && json.status === false)) { throw new Error(json?.message || `Fiesta ${endpoint} failed: ${res.status} ${res.statusText}`); } return json as T; } /** * Fiesta envelopes responses as `{ code, details, message, status }`. `details` * is usually an array of rows, sometimes a single object, sometimes null. */ export function toRows(json: unknown): T[] { if (Array.isArray(json)) return json as T[]; if (json && typeof json === 'object') { const d = (json as { details?: unknown }).details; if (Array.isArray(d)) return d as T[]; if (d && typeof d === 'object') return [d as T]; } return []; } export function firstRow(json: unknown): T | null { const d = (json as { details?: unknown })?.details; if (Array.isArray(d)) return (d.length ? (d[0] as T) : null); if (d && typeof d === 'object') return d as T; return null; } export function num(v: unknown): number { const n = typeof v === 'number' ? v : Number(v); return Number.isFinite(n) ? n : 0; } export const str = (v: unknown): string => (v == null ? '' : String(v)); /** * Display name for a customer row. `gettenantcustomers` returns `firstname` / * `lastname` and has NO `customername` or `name` column, so code that read those * fell through to "Unknown Customer" for every customer on the platform. Delivery * rows spell the same person `deliverycustomer`, hence the extra fallbacks. */ export function customerName(r: Row): string { const full = `${str(r.firstname).trim()} ${str(r.lastname).trim()}`.trim(); return ( full || str(r.deliverycustomer).trim() || str(r.customername).trim() || str(r.name).trim() ); } /** * The store a customer belongs to. `gettenantcustomers` aliases the * `tenantcustomers.locationid` link as `tenantlocationid` — plain `locationid` * is NOT in the response, so filtering on it matched nothing. `deliverylocationid` * is the saved-address id, a different thing entirely, and must not be used here. */ export const customerStoreId = (r: Row): number => num(r.tenantlocationid) || num(r.locationid); /** Fiesta date params want a bare `YYYY-MM-DD`. */ export const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; // ════════════════════════════════════════════════════════════════════════════ // ORDERS // ════════════════════════════════════════════════════════════════════════════ export interface FiestaOrderSummary { total: number; created: number; pending: number; processing: number; delivered: number; cancelled: number; tenantid?: number; tenantname?: string; } export interface FiestaLocationRevenue { locationid: number; locationname: string; revenue: number; } export interface FiestaRevenueSummary { tenantid: number; tenantname: string; overallrevenue: number; locationrevenue: FiestaLocationRevenue[]; } /** /orders/getrevenuesummary?tenantid=&locationid=&fromdate=&todate= — tenant revenue and location breakdown. */ export async function getRevenueSummary(opts: { tenantid: number; fromdate?: string; todate?: string; locationid?: number; }) { const res = await fiestaGet('orders/getrevenuesummary', opts); return firstRow<{ tenantid: number; tenantname: string; overallrevenue: number; locationrevenue: Array<{ locationid: number; locationname: string; revenue: number; }>; }>(res); } /** /orders/getordersummary?tenantid=&locationid=&fromdate=&todate= — flat order counts. */ export async function getOrderSummary( tenantid: number, fromdate: string, todate: string, locationid?: number, ): Promise { const row = firstRow(await fiestaGet('orders/getordersummary', { tenantid, locationid, applocationid: locationid, // send both to bypass backend bugs fromdate, todate })); if (!row) return null; return { total: num(row.total), created: num(row.created), pending: num(row.pending), processing: num(row.processing), delivered: num(row.delivered), cancelled: num(row.cancelled), tenantid: row.tenantid != null ? num(row.tenantid) : undefined, tenantname: typeof row.tenantname === 'string' ? row.tenantname : undefined, }; } export interface FiestaLocationSummary { locationid: number; locationname: string; total: number; created: number; pending: number; processing: number; delivered: number; cancelled: number; } /** /orders/getlocationsummary?tenantid=&fromdate=&todate= — per-outlet order rollup. */ export async function getLocationSummary(tenantid: number, fromdate?: string, todate?: string): Promise { return toRows(await fiestaGet('orders/getlocationsummary', { tenantid, fromdate, todate })).map((r) => ({ locationid: num(r.locationid), locationname: str(r.locationname), total: num(r.total), created: num(r.created), pending: num(r.pending), processing: num(r.processing), delivered: num(r.delivered), cancelled: num(r.cancelled), })); } /** /orders/getorderinsight?tenantid= — per-location monthly order counts. */ export async function getOrderInsight(tenantid: number): Promise { return toRows(await fiestaGet('orders/getorderinsight', { tenantid })); } /** /orders/getorders?tenantid=&locationid=&applocationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — orders board. */ export async function getOrders(opts: { tenantid: number; status: string; fromdate: string; todate: string; locationid?: number; applocationid?: number; keyword?: string; pageno?: number; pagesize?: number; }): Promise { return toRows( await fiestaGet('orders/getorders', { tenantid: opts.tenantid, locationid: opts.locationid, applocationid: opts.applocationid, status: opts.status, fromdate: opts.fromdate, todate: opts.todate, keyword: opts.keyword, pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 20, }), ); } /** /orders/gettimeseries?tenantid=&locationid=&granularity=&fromdate=&todate= — time-series chart data */ export async function getTimeSeries(opts: { tenantid: number; granularity: 'day' | 'month' | 'year'; fromdate: string; todate: string; locationid?: number; }) { const res = await fiestaGet('orders/gettimeseries', opts); return toRows<{ label: string; orders: number; revenue: number; delivered: number; cancelled: number; activeskus: number; }>(res); } /** /orders/getorderdetails?orderheaderid= — line items for a single order. */ export async function getOrderDetails(orderheaderid: number | string): Promise { let cleanId = String(orderheaderid).trim(); if (cleanId.toUpperCase().startsWith('DLV-')) { cleanId = cleanId.substring(4); } cleanId = cleanId.split('-')[0]; const numericId = Number(cleanId); const finalId = Number.isInteger(numericId) && numericId > 0 ? numericId : orderheaderid; return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid: finalId })); } /** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */ export async function getCustomerOrders(opts: { customerid: number | string; status?: string; pageno?: number; pagesize?: number; }): Promise { return toRows( await fiestaGet('orders/getorders', { customerid: opts.customerid, status: opts.status ?? '', pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 20, }), ); } // ════════════════════════════════════════════════════════════════════════════ // DELIVERIES // ════════════════════════════════════════════════════════════════════════════ export interface FiestaDeliverySummary { total: number; created: number; pending: number; accepted: number; arrived: number; picked: number; active: number; delivered: number; cancelled: number; } /** /deliveries/deliverysummary?tenantid=&applocationid=&locationid=&fromdate=&todate= — dispatch counts. */ export async function getDeliverySummary(opts: { tenantid: number; applocationid?: number; locationid?: number; fromdate: string; todate: string; }): Promise { const row = firstRow( await fiestaGet('deliveries/deliverysummary', { tenantid: opts.tenantid, applocationid: opts.applocationid, // only sent when provided (no forced default) locationid: opts.locationid, fromdate: opts.fromdate, todate: opts.todate, }), ); if (!row) return null; return { total: num(row.total), created: num(row.created), pending: num(row.pending), accepted: num(row.accepted), arrived: num(row.arrived), picked: num(row.picked), active: num(row.active), delivered: num(row.delivered), cancelled: num(row.cancelled), }; } /** /deliveries/getdeliveries?tenantid=&applocationid=&locationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — the master deliveries board. */ export async function getDeliveries(opts: { tenantid: number; fromdate: string; todate: string; status?: string; locationid?: number; applocationid?: number; keyword?: string; pageno?: number; pagesize?: number; }): Promise { const pagesize = opts.pagesize ?? 200; const fetchPage = async (pageno: number) => toRows( await fiestaGet('deliveries/getdeliveries', { tenantid: opts.tenantid, // NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid // filter on THIS endpoint is broken: passing a real outlet id returns [] // (it doesn't match against the row's own `locationid`), even though // deliverysummary honours the same id and the rows clearly carry it. So we // fetch tenant-wide here and scope by locationid client-side below; the KPI // strip (deliverysummary) keeps using the working server-side filter. applocationid: opts.applocationid, // The backend treats `status` as a LITERAL orderstatus filter — passing // 'all' matches nothing (returns []). Send empty to fetch every status and // let the board filter client-side by its status tabs. status: !opts.status || opts.status === 'all' ? '' : opts.status, fromdate: opts.fromdate, todate: opts.todate, keyword: opts.keyword, pageno, pagesize, }), ); let rows: Row[]; if (opts.pageno) { // An explicit page was asked for — honour it and don't walk the rest. rows = await fetchPage(opts.pageno); } else { // Walk every page. The endpoint has no total-count field, so a short page is // the only end-of-data signal. Previously this fetched page 1 only, which // silently dropped delivery 201+ for a busy tenant-wide day — the dispatch // board looked complete while missing stops. MAX_PAGES caps a runaway loop // if the backend ever ignores `pageno` and keeps returning full pages. const MAX_PAGES = 25; rows = []; for (let page = 1; page <= MAX_PAGES; page++) { const batch = await fetchPage(page); rows.push(...batch); if (batch.length < pagesize) break; } } return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows; } /** /deliveries/getdeliveryinsight?tenantid= — daily delivery insight. */ export async function getDeliveryInsight(tenantid: number): Promise { return toRows(await fiestaGet('deliveries/getdeliveryinsight', { tenantid })); } /** /deliveries/getdeliveryreport?tenantid=&applocationid=&partnerid=&userid=&fromdate=&todate= — * deliveries financial report summary (per the endpoint sheet). */ export async function getDeliveryReport(opts: { tenantid: number; applocationid?: number; partnerid?: number; userid?: number; fromdate: string; todate: string; }): Promise { return toRows( await fiestaGet('deliveries/getdeliveryreport', { tenantid: opts.tenantid, applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID, partnerid: opts.partnerid, userid: opts.userid, fromdate: opts.fromdate, todate: opts.todate, }), ); } /** /partners/getfleetsummary?applocationid=&partnerid=&tenantid=&fromdate=&todate= — * fleet rider summary metrics (per the endpoint sheet). */ export async function getFleetSummary(opts: { applocationid?: number; partnerid?: number; tenantid: number; fromdate: string; todate: string; }): Promise { return toRows( await fiestaGet('partners/getfleetsummary', { applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID, partnerid: opts.partnerid, tenantid: opts.tenantid, fromdate: opts.fromdate, todate: opts.todate, }), ); } /** `YYYY-MM-DD HH:mm:ss` — the timestamp format the delivery endpoints expect. */ function nowStamp(): string { const d = new Date(); const p = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } /** * Build a delivery record from a getorders row for POST /createdeliveries. The * backend keys the order (orderheaderid) and the rider (userid), copies the * pickup/drop snapshot, and must carry the SAME tenant/partner/applocation as * the order so the assignment is valid. Order field names map 1:1 (Go's JSON * decode is case-insensitive, so `pickupaddress` satisfies `Pickupaddress`). */ function deliveryFromOrder(o: Row, userid: number, assigntime: string): Row { return { orderheaderid: num(o.orderheaderid), orderid: str(o.orderid), applocationid: num(o.applocationid), configid: num(o.configid) || 1, partnerid: num(o.partnerid), tenantid: num(o.tenantid), moduleid: num(o.moduleid), locationid: num(o.locationid), categoryid: num(o.categoryid), subcategoryid: num(o.subcategoryid), userid, // the assigned rider customerid: num(o.customerid), orderstatus: 'pending', assigntime, // The Orders API exposes the scheduled delivery date as `deliverytime` (there // is no `deliverydate` on an order row). Copy it through so the new delivery // lands in the Deliveries board's date window — falling back to the order date // and finally the assign timestamp so the row is never written date-less // (a date-less delivery is excluded by getdeliveries' from/to filter). deliverydate: str(o.deliverydate) || str(o.deliverytime) || str(o.orderdate) || assigntime, itemcount: num(o.itemcount), orderamount: num(o.orderamount) || num(o.deliveryamt), deliveryamt: num(o.deliveryamt), deliverycharges: num(o.deliverycharge) || num(o.deliverycharges), paymenttype: num(o.paymenttype), ordernotes: str(o.ordernotes), pickupcustomer: str(o.pickupcustomer) || str(o.tenantname), pickupcontactno: str(o.pickupcontactno), pickupaddress: str(o.pickupaddress), pickuplocationid: num(o.pickuplocationid), pickuplat: str(o.pickuplat), pickuplon: str(o.pickuplong) || str(o.pickuplon), deliverycustomerid: num(o.deliverycustomerid), deliverylocationid: num(o.deliverylocationid), deliverycustomer: str(o.deliverycustomer), deliverycontactno: str(o.deliverycontactno), deliveryaddress: str(o.deliveryaddress), deliverylat: str(o.deliverylat), deliverylong: str(o.deliverylong), }; } /** * Assign a rider to one or more orders — the CORRECT flow per the backend: * • orders with no delivery yet (`deliveryid == 0`, i.e. freshly created) → * POST /deliveries/createdeliveries (one batched call). This creates the * delivery, enqueues it, AND flips the order to `pending`. * • orders that already have a delivery → PUT /deliveries/updatedelivery to * re-point the rider. * The rider (userid) MUST belong to the same tenant/partner as the orders, or * the backend rejects the assignment — that scoping is enforced on the rider * list (see getRiders' partnerid). */ export async function assignRiderToOrders( userid: number, orders: Row[], ): Promise<{ ok: number; failed: number; total: number }> { const assigntime = nowStamp(); const toCreate = orders.filter((o) => !num(o.deliveryid)); const toUpdate = orders.filter((o) => num(o.deliveryid)); let ok = 0; let failed = 0; if (toCreate.length) { try { await fiestaSend('deliveries/createdeliveries', 'POST', toCreate.map((o) => deliveryFromOrder(o, userid, assigntime))); ok += toCreate.length; } catch { failed += toCreate.length; } } if (toUpdate.length) { const results = await Promise.allSettled( toUpdate.map((o) => fiestaSend('deliveries/updatedelivery', 'PUT', { userid, deliveryid: num(o.deliveryid), orderheaderid: num(o.orderheaderid), orderstatus: 'pending', assigntime, }), ), ); ok += results.filter((r) => r.status === 'fulfilled').length; failed += results.filter((r) => r.status === 'rejected').length; } return { ok, failed, total: orders.length }; } // ════════════════════════════════════════════════════════════════════════════ // RIDER PUSH NOTIFICATION // ════════════════════════════════════════════════════════════════════════════ /** * Thrown when the rider has no registered device. Distinct from a transport * failure because the remedy is different — the rider must open the app and * sign in, not retry. Without this the operator sees a generic "notification * failed" and assumes the network is at fault. */ export class RiderNotReachableError extends Error { constructor(message = 'This rider has no device registered, so they were not notified.') { super(message); this.name = 'RiderNotReachableError'; } } export interface NotifyRiderInput { token: string; title?: string; body: string; /** Silent payload the rider app switches on, e.g. `{ type: 'cancel' }`. */ data?: Record; } /** * POST /utils/notifyuser — relays an FCM push to a rider through the backend, * which holds the Firebase service account. * * Fire-and-forget by design: there is no delivery receipt and no retry. The * delivery row is already committed by the time this runs, so a failure here * means the rider has work they have not been told about — which is why it is * surfaced to the operator rather than swallowed. */ export async function notifyRider(input: NotifyRiderInput): Promise { const token = (input.token ?? '').trim(); // Checked before the request: posting an empty token returns a generic FCM // "invalid argument", which reads as a server fault rather than a rider who // has never opened the app. if (!token) throw new RiderNotReachableError(); return fiestaSend('utils/notifyuser', 'POST', { token, notification: { title: input.title ?? 'NearleXpress', body: input.body, sound: 'ring', image: '', }, ...(input.data ? { data: input.data } : {}), }); } /** Standard message bodies, kept together so the wording stays consistent. */ export const RIDER_MESSAGES = { 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.`, reassigned: 'A delivery has been assigned to you. Kindly accept and process it.', reminder: 'You have deliveries waiting. Kindly accept and process them.', cancelled: (orderid: string) => `${orderid} has been cancelled.`, } as const; // ════════════════════════════════════════════════════════════════════════════ // PARTNERS / RIDERS // ════════════════════════════════════════════════════════════════════════════ /** * /partners/getriders?applocationid=&partnerid=&tenantid= — riders on duty NOW. * * Despite the name this is a presence query, not a roster. The backend requires * status='Active', onduty=1, and a riderlog dated today with logstatus=0, then * joins each rider's most recent GPS ping. So it answers "who is working right * now", and the rows carry userfcmtoken for notifying them. * * Scope by applocationid or partnerid. NOT by tenantid: a rider record leaves * app_users.tenantid unset (riders belong to a partner and an app-location), so * a tenant-scoped call returns an empty list for every tenant. The backend * checks applocationid first, then partnerid, then tenantid, so passing an * app-location alongside anything else wins. */ export async function getRiders(opts: { applocationid?: number; tenantid?: number; partnerid?: number; }): Promise { const scoped = opts.applocationid || opts.partnerid || opts.tenantid; return toRows( await fiestaGet('partners/getriders', { applocationid: scoped ? opts.applocationid : FIESTA_APPLOCATION_ID, tenantid: opts.tenantid, partnerid: opts.partnerid, }), ); } /** /partners/getridershifts?applocationid= — rider shift records. */ export async function getRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID): Promise { return toRows(await fiestaGet('partners/getridershifts/', { applocationid })); } // ════════════════════════════════════════════════════════════════════════════ // TENANTS / CUSTOMERS // ════════════════════════════════════════════════════════════════════════════ /** * Throwaway/test email providers. A location whose contact email is on one of * these is a sandbox record, never a real outlet — used to drop test data. */ const DISPOSABLE_EMAIL_DOMAINS = new Set([ 'mailinator.com', 'mailinator.net', 'example.com', 'example.org', 'test.com', 'yopmail.com', 'guerrillamail.com', '10minutemail.com', ]); /** * The tenant-locations endpoint for some tenants returns junk: the primary * outlet duplicated several times, plus orphan test records geocoded to random * countries (e.g. "Deborah Lara, Spain", "power, Ireland") with throwaway * emails. This strips both so the registry/inventory show only real outlets. * * The filter is self-calibrating (no hardcoded names/ids): it derives the * tenant's operating region from the most common state among its outlets, then * drops rows that either use a disposable email or sit outside that region. If * the region can't be established (no state data), nothing is region-filtered — * we'd rather show an extra row than hide a genuine outlet. */ export function cleanTenantLocations(rows: Row[]): Row[] { // 1. Dedupe by locationid — the API repeats the primary outlet. const seen = new Set(); const deduped = rows.filter((r) => { const id = num(r.locationid); if (!id || seen.has(id)) return false; seen.add(id); return true; }); // 2. Find the tenant's home region (plurality of `state`). const stateCounts = new Map(); for (const r of deduped) { const st = str(r.state).trim().toLowerCase(); if (st) stateCounts.set(st, (stateCounts.get(st) ?? 0) + 1); } let homeState = ''; let max = 0; for (const [st, c] of stateCounts) { if (c > max) { max = c; homeState = st; } } // 3. Drop disposable-email rows and out-of-region rows. return deduped.filter((r) => { const emailDomain = (str(r.email).split('@')[1] ?? '').trim().toLowerCase(); if (emailDomain && DISPOSABLE_EMAIL_DOMAINS.has(emailDomain)) return false; if (homeState) { const st = str(r.state).trim().toLowerCase(); if (st && st !== homeState) return false; } return true; }); } /** /tenants/gettenantlocations?tenantid=&userid= — outlet locations for a tenant (test rows stripped). */ export async function getTenantLocations(tenantid: number, userid?: number): Promise { const params: Record = { tenantid }; if (userid) params.userid = userid; return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', params))); } /** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */ export async function getAllTenants(opts: { applocationid?: number; status?: string; pageno?: number; pagesize?: number; } = {}): Promise { return toRows( await fiestaGet('tenants/getalltenants', { applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID, status: opts.status ?? 'Active', pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 20, }), ); } /** * Collapse the gettenantcustomers rows to one per customer. The endpoint returns * one row per saved DELIVERY ADDRESS (each carries its own deliverylocationid / * address), so a customer with several addresses repeats many times. Key by * customerid (fall back to contactno), preferring the row flagged primaryaddress; * rows with no identity at all are kept as-is so nothing is silently dropped. */ export function dedupeCustomers(rows: Row[]): Row[] { const byCustomer = new Map(); for (const r of rows) { const cid = num(r.customerid); const key = cid ? `c${cid}` : (str(r.contactno) ? `p${str(r.contactno)}` : `x${byCustomer.size}`); const existing = byCustomer.get(key); if (!existing || (num(r.primaryaddress) && !num(existing.primaryaddress))) { byCustomer.set(key, r); } } return [...byCustomer.values()]; } /** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= (deduped per customer). */ export async function getTenantCustomers(opts: { tenantid: number; locationid: number; keyword?: string; pageno?: number; pagesize?: number; }): Promise { return dedupeCustomers(toRows( await fiestaGet('customers/gettenantcustomers', { tenantid: opts.tenantid, locationid: opts.locationid, keyword: opts.keyword ?? '', pageno: opts.pageno ?? 1, // When a store is named the backend joins `customerlocations`, so it // returns one row per SAVED ADDRESS and applies LIMIT to those rows — not // to customers. Live: locationid=1185 → 12 rows → 2 customers (11 of them // one person's addresses). The old default of 20 therefore showed a store // roughly three customers. Ask for enough rows that dedupe still has every // customer to work with; the backend's DISTINCT ON fix makes this generous // rather than load-bearing. pagesize: opts.pagesize ?? 500, }), )); } // ════════════════════════════════════════════════════════════════════════════ // PRODUCTS / STOCK // ════════════════════════════════════════════════════════════════════════════ /** /products/getstockstatement?tenantid=&locationid=&subcategoryid=&keyword=&pageno=&pagesize= */ export async function getStockStatement(opts: { tenantid: number; locationid: number; subcategoryid?: number; keyword?: string; pageno?: number; pagesize?: number; }): Promise { return toRows( await fiestaGet('products/getstockstatement', { tenantid: opts.tenantid, locationid: opts.locationid, subcategoryid: opts.subcategoryid, keyword: opts.keyword ?? '', pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 50, }), ); } /** /products/getproductscount?tenantid=&categoryid=&subcategoryid=&approve= */ export async function getProductsCount(opts: { tenantid: number; categoryid: number; subcategoryid?: number; }): Promise { return firstRow( await fiestaGet('products/getproductscount', { tenantid: opts.tenantid, categoryid: opts.categoryid, subcategoryid: opts.subcategoryid, approve: 1, }), ); } /** /products/getproductstocks?tenantid=&locationid= — live stock levels for an outlet. */ export async function getProductStocks(opts: { tenantid: number; locationid: number; }): Promise { return toRows( await fiestaGet('products/getproductstocks', { tenantid: opts.tenantid, locationid: opts.locationid, }), ); } /** /products/getlocationproducts?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= — * geofenced per-outlet inventory. */ export async function getProductLocations(opts: { tenantid: number; locationid: number; subcategoryid?: number; pageno?: number; pagesize?: number; }): Promise { return toRows( await fiestaGet('products/getlocationproducts', { tenantid: opts.tenantid, locationid: opts.locationid, subcategoryid: opts.subcategoryid, pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 50, }), ); } /** /products/getproducts?tenantid=&locationid=&subcategoryid=&keyword=&pageno=&pagesize= — * master catalog listings (global assortment). */ export async function getMasterCatalog(opts: { tenantid: number; locationid?: number; subcategoryid?: number; keyword?: string; pageno?: number; pagesize?: number; }): Promise { return toRows( await fiestaGet('products/getproducts', { tenantid: opts.tenantid, locationid: opts.locationid, subcategoryid: opts.subcategoryid, keyword: opts.keyword ?? '', pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 50, }), ); } export interface CreateProductLocationInput { tenantid: number; locationid: number; productid: number; quantity?: number; // User prompt specified quantity qty?: number; // Keep for backwards compatibility if needed stocktype?: string; status?: string; price?: number; } /** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */ export async function createProductLocation(input: CreateProductLocationInput): Promise { // The selling price field is `price` — it binds to models.Productlocations // .Price, and CreateProductLocation's upsert lists "price" in its // DoUpdates, so this both inserts and updates the per-store price. // (`retailprice` is the MASTER price on the products table; that struct // ignores it, so sending it here persisted nothing.) Only send when a price // was supplied, so a quantity-only write can't blank an existing one. const payload: Record = { tenantid: input.tenantid, locationid: input.locationid, productid: input.productid, quantity: input.quantity ?? input.qty ?? 0, stocktype: input.stocktype || 'in', status: input.status || 'Active', }; if (num(input.price) > 0) payload.price = input.price; return fiestaSend('products/createproductlocation', 'POST', [payload]); } export interface DeleteProductLocationInput { tenantid: number; locationid: number; productid: number; } /** DELETE /products/deleteproductlocation — Remove a product from a store catalogue. */ export async function deleteProductLocation(input: DeleteProductLocationInput): Promise { return fiestaSend('products/deleteproductlocation', 'DELETE', input); } export interface StockRequestInput { tenantid: number; locationid: number; productid: number; qty: number; status?: string; locationname?: string; productname?: string; productimage?: string; tenantname?: string; } /** POST /products/createstockrequest — Store user requests stock. */ export async function createStockRequest(input: StockRequestInput): Promise { return fiestaSend('products/createstockrequest', 'POST', input); } /** /products/getstockrequests?tenantid=&locationid=&status=&pageno=&pagesize= — * Fetch pending/approved stock requests. */ export async function getStockRequests(opts: { tenantid: number; locationid?: number; status?: string; date?: string; pageno?: number; pagesize?: number; }): Promise { return toRows( await fiestaGet('products/getstockrequests', { tenantid: opts.tenantid, locationid: opts.locationid, status: opts.status ?? '', date: opts.date, pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 50, }), ); } export interface UpdateStockRequestInput { requestid?: number; tenantid?: number; locationid?: number; productid?: number; status: string; } /** PUT /products/updatestockrequest — Admin approves or rejects a stock request. */ export async function updateStockRequest(input: UpdateStockRequestInput): Promise { return fiestaSend('products/updatestockrequest', 'PUT', input); } /** /products/getproductcategories — global product categories. */ export async function getProductCategories(): Promise { return toRows(await fiestaGet('products/getproductcategories', {})); } /** /products/getproductsubcategories?categoryid=&tenantid= — subcategories under a category. */ export async function getProductSubcategories(opts: { categoryid: number; tenantid?: number; }): Promise { return toRows( await fiestaGet('products/getproductsubcategories', { categoryid: opts.categoryid, tenantid: opts.tenantid, }), ); } // ════════════════════════════════════════════════════════════════════════════ // USERS // ════════════════════════════════════════════════════════════════════════════ /** Best-effort role label from the numeric roleid (roles aren't fully resolvable for every config). */ export function roleName(roleid: number): string { const map: Record = { [-1]: 'Unassigned', 0: 'Unassigned', 1: 'Owner', 2: 'Manager', 3: 'Admin', 4: 'Staff', 5: 'Rider', 6: 'Cashier', }; return map[roleid] || `Role ${roleid}`; } /** /users/getallusers?roleid=&tenantid=&pageno=&pagesize=&keyword= — staff/users under a tenant. */ export async function getAllUsers(opts: { tenantid: number; roleid?: number; keyword?: string; pageno?: number; pagesize?: number; }): Promise { return toRows( await fiestaGet('users/getallusers', { tenantid: opts.tenantid, roleid: opts.roleid, keyword: opts.keyword ?? '', pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 50, }), ); } /** /users/getusers?userid= — a single user profile. */ export async function getUserById(userid: number): Promise { return firstRow(await fiestaGet('users/getusers', { userid })); } export interface CreateUserInput { firstname: string; lastname?: string; email: string; contactno: string; /** Optional — merchant_web's create form doesn't collect one. */ password?: string; roleid: number; /** * app_users.configid — the same column every login call filters on * (auth.ts hardcodes configid: 1). Must stay 1 or the account can never * be found on login; this is NOT the unrelated Hasura "role config" id. */ configid?: number; /** Business module id (merchant_web sends the logged-in user's; 0 when absent). */ moduleid?: number; dialcode?: string; pin?: number; address?: string; suburb?: string; city?: string; state?: string; postcode?: string; latitude?: string; longitude?: string; /** Rider shift (only meaningful for rider-role users). */ shiftid?: number; tenantid: number; locationid?: number; applocationid?: number; status?: string; } /** POST /users/create — register a new web staff user. */ export async function createUser(input: CreateUserInput): Promise { return fiestaSend('users/create', 'POST', { authname: input.email, firstname: input.firstname, lastname: input.lastname ?? '', password: input.password ?? '', email: input.email, dialcode: input.dialcode ?? '+91', contactno: input.contactno, roleid: input.roleid, configid: input.configid ?? 1, moduleid: input.moduleid ?? 0, pin: input.pin ?? 0, address: input.address ?? '', suburb: input.suburb ?? '', city: input.city ?? '', state: input.state ?? '', postcode: input.postcode ?? '', latitude: input.latitude ?? '', longitude: input.longitude ?? '', shiftid: input.shiftid ?? 0, tenantid: input.tenantid, locationid: input.locationid ?? 0, applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID, status: input.status ?? 'active', }); } export interface UpdateUserInput { userid: number; firstname?: string; lastname?: string; email?: string; contactno?: string; address?: string; suburb?: string; city?: string; state?: string; postcode?: string; status?: string; roleid?: number | null; locationid?: number | null; applocationid?: number | null; } /** PUT /users/update — update an existing web staff user. */ export async function updateUser(input: UpdateUserInput): Promise { return fiestaSend('users/update', 'PUT', input); } /** * PUT /users/update — set (or reset) a user's login password. Same endpoint as * `updateUser`, called with only userid + password so every other column is * left untouched (the backend's Updates() skips zero-value fields). */ export async function setUserPassword(userid: number, password: string): Promise { return fiestaSend('users/update', 'PUT', { userid, password }); } export interface CreateTenantLocationPayload { locationname: string; email?: string; contactno?: string; address?: string; suburb?: string; city?: string; state?: string; postcode?: string; latitude?: string; longitude?: string; applocationid?: number; } export interface CreateTenantInput { tenantname: string; companyname: string; primarycontact: string; primaryemail: string; address?: string; suburb?: string; city?: string; state?: string; postcode?: string; latitude?: string; longitude?: string; approved?: number; status?: string; applocationid?: number; categoryid?: number; /** Auto-provisions the tenant's primary (Active) location in the same call. */ tenantlocations?: CreateTenantLocationPayload; } /** POST /tenants/createtenantuser — Onboard a new tenant, primary location, and admin user. */ export async function createTenantUser(input: CreateTenantInput): Promise { const res = await fetch(`${FIESTA_BASE}/tenants/createtenantuser`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); const json = (await res.json().catch(() => null)) as { message?: string; status?: boolean } | null; if (!res.ok || (json && json.status === false)) { throw new Error(json?.message || `Tenant onboarding failed: ${res.status}`); } return json as Row; } export interface CreateTenantLocationInput { tenantid: number; locationname: string; address?: string; suburb?: string; city?: string; state?: string; postcode?: string; contactno?: string; email?: string; opentime?: string; closetime?: string; deliverymins?: number; deliveryradius?: number; latitude?: string; longitude?: string; status?: string; moduleid?: number; applocationid?: number; partnerid?: number; roleid?: number; } /** * POST /tenants/createtenantlocation — Create a new store location under a tenant. * The envelope's `details` is the created location row (including the * DB-assigned `locationid`), needed right after onboarding to build the * store's QR code via `buildStoreQrPayload`. */ export async function createTenantLocation(input: CreateTenantLocationInput): Promise { return fiestaSend('tenants/createtenantlocation', 'POST', input); } export interface UpdateTenantLocationInput { locationid: number; tenantid?: number; locationname?: string; contactno?: string; email?: string; status?: string; suburb?: string; city?: string; } /** PUT /tenants/updatetenantlocation — Update store location details/status. */ export async function updateTenantLocation(input: UpdateTenantLocationInput): Promise { return fiestaSend('tenants/updatetenantlocation', 'PUT', input); } // ════════════════════════════════════════════════════════════════════════════ // RIDERS / DISPATCH // ════════════════════════════════════════════════════════════════════════════ /** /riders/getriderperiodiclogs?userid=&riderid=&fromdate=&todate=&tenantid=&applocationid= — * periodic GPS/status snapshots for a rider across a date range. */ export async function getRiderPeriodicLogs(opts: { userid?: number; riderid?: number; fromdate: string; todate: string; tenantid?: number; applocationid?: number; }): Promise { return toRows( await fiestaGet('riders/getriderperiodiclogs', { userid: opts.userid, riderid: opts.riderid, fromdate: opts.fromdate, todate: opts.todate, tenantid: opts.tenantid, applocationid: opts.applocationid, }), ); } /** /partners/getriderlogs?userid=&riderid=&fromdate=&todate=&tenantid=&applocationid= — * full telemetry logs (GPS traces, events, etc.) for a rider. */ export async function getRiderLogs(opts: { userid?: number; riderid?: number; fromdate: string; todate: string; tenantid?: number; applocationid?: number; pageno?: number; pagesize?: number; }): Promise { return toRows( await fiestaGet('partners/getriderlogs', { userid: opts.userid, riderid: opts.riderid, fromdate: opts.fromdate, todate: opts.todate, tenantid: opts.tenantid, applocationid: opts.applocationid, pageno: opts.pageno ?? 1, pagesize: opts.pagesize ?? 200, }), ); } /** /partners/getbatchefficiency?partnerid=&tenantid=&fromdate=&todate= — * batch/trip efficiency metrics. */ export async function getBatchEfficiency(opts: { partnerid?: number; tenantid: number; fromdate: string; todate: string; }): Promise { return toRows( await fiestaGet('partners/getbatchefficiency', { partnerid: opts.partnerid, tenantid: opts.tenantid, fromdate: opts.fromdate, todate: opts.todate, }), ); } /** PUT /deliveries/updatedelivery — Manually assign/update a delivery's rider or status. */ export async function updateDelivery(deliveryid: number, updates: Row): Promise { return fiestaSend('deliveries/updatedelivery', 'PUT', { deliveryid, ...updates, }); } /** * Move one delivery to a different rider. * * Goes through PUT /deliveries/updatedelivery. There is no batch reassign * endpoint: /riders/reassigndeliveries, which this used to POST to, is not * registered on the backend and answers 404 — it had no callers, so the failure * was never observed. * * Re-assigning resets orderstatus to 'pending', discarding any accepted/arrived * progress the previous rider had made. That is the backend's existing * behaviour and callers should gate the action accordingly. */ export async function changeDeliveryRider(opts: { deliveryid: number; orderheaderid: number; userid: number; }): Promise { return fiestaSend('deliveries/updatedelivery', 'PUT', { deliveryid: opts.deliveryid, orderheaderid: opts.orderheaderid, userid: opts.userid, orderstatus: 'pending', assigntime: nowStamp(), }); } /** Reassign several deliveries to one rider, one call each. Tolerates partial * failure and reports it, the same contract as assignRiderToOrders. */ export async function reassignDeliveries(opts: { userid: number; deliveries: { deliveryid: number; orderheaderid: number }[]; }): Promise<{ ok: number; failed: number; total: number }> { const results = await Promise.allSettled( opts.deliveries.map((d) => changeDeliveryRider({ deliveryid: d.deliveryid, orderheaderid: d.orderheaderid, userid: opts.userid }), ), ); return { ok: results.filter((r) => r.status === 'fulfilled').length, failed: results.filter((r) => r.status === 'rejected').length, total: opts.deliveries.length, }; } /** POST /v1/web/tenants/createlocation — Create a new tenant location (outlet). */ export async function createLocation(opts: { tenantid: number; locationname: string; suburb: string; contactno?: string; status?: string; }): Promise { // Use fiestaSend, note that we include /v1/web explicitly here as the FIESTA_BASE is likely just the root. // Wait, let's look at FIESTA_BASE in the file. // Actually, I'll just use the full endpoint path. return fiestaSend('v1/web/tenants/createlocation', 'POST', opts); } /** PUT /v1/web/tenants/updatelocation — Update an existing tenant location (outlet). */ export async function updateLocation(opts: { tenantid: number; locationid: number; locationname?: string; suburb?: string; contactno?: string; status?: string; }): Promise { return fiestaSend('v1/web/tenants/updatelocation', 'PUT', opts); } /** DELETE /v1/web/tenants/deletelocation — Delete an existing tenant location (outlet). */ export async function deleteLocation(opts: { tenantid: number; locationid: number; }) { const qs = new URLSearchParams({ tenantid: String(opts.tenantid), locationid: String(opts.locationid), }).toString(); return await fiestaSend(`tenants/deletelocation?${qs}`, 'POST'); } export interface SalesSummaryChartData { date: string; revenue: number; orders: number; } export interface SalesSummaryTopLocation { locationname: string; revenue: number; } export interface SalesSummaryResponse { totalRevenue: number; totalOrders: number; averageOrderValue: number; chartData: SalesSummaryChartData[]; topLocations: SalesSummaryTopLocation[]; } /** GET /v1/web/reports/sales-summary — Fetches aggregated sales and revenue data. */ export async function getSalesSummary(opts: { tenantid: number; locationid?: number; fromdate: string; todate: string; }): Promise { // Use fiestaGet wrapper which parses standard responses const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts); return res.details; } // ════════════════════════════════════════════════════════════════════════════ // OFFLINE (IN-STORE) SALES // ════════════════════════════════════════════════════════════════════════════ export interface SaleTemplateRow { tenantid: number; locationid: number; locationname: string; productid: number; productname: string; productunit: string; unitvalue: string; categoryname: string; currentstock: number; price: number; taxpercent: number; } export interface SaleTemplateLocation { locationid: number; locationname: string; productcount: number; } export interface SaleTemplate { tenantid: number; /** 0 when the template spans every branch of the tenant. */ locationid: number; locations: SaleTemplateLocation[]; products: SaleTemplateRow[]; } /** * GET /products/getsaletemplate — products stocked across the tenant's * branches, each with its live ledger balance and price. * * `locationid` is optional and defaults to every branch, which is the normal * case: one workbook covers the whole business and each row carries the branch * its stock belongs to. Pass a locationid to narrow it to a single store. * * This is what the offline-sales spreadsheet is built from, and the reason it * has to be generated rather than hand-written: `productid` is the only usable * key for a product. Across the live catalogue 6,245 products share just 93 * distinct `productsku` values (one tenant has 463 products all carrying sku * "1"), so a store cannot identify a product by SKU, and product names are not * unique enough either. Pre-filling productid and locationid removes both * problems at once. */ export async function getSaleTemplate(opts: { tenantid: number; locationid?: number; }): Promise { const res = await fiestaGet<{ details: SaleTemplate | null }>('products/getsaletemplate', { tenantid: opts.tenantid, locationid: opts.locationid ?? 0, }); if (!res?.details) throw new Error('No products are stocked at any of your outlets yet.'); return res.details; } export interface OfflineSaleItemInput { productid: number; productname?: string; qtysold: number; unitprice?: number; discountamount?: number; taxpercent?: number; } export interface OfflineSaleBillInput { /** Branch this bill was rung up at, taken from the spreadsheet row. */ locationid: number; billno?: string; saledate?: string; paymentmode?: string; customername?: string; customermobile?: string; remarks?: string; items: OfflineSaleItemInput[]; } export interface OfflineSaleResult { locationid: number; locationname: string; billno: string; status: 'imported' | 'duplicate' | 'failed'; orderid: string; orderheaderid: number; itemcount: number; amount: number; message: string; } export interface OfflineSalesUploadResponse { imported: number; duplicate: number; failed: number; totalamount: number; results: OfflineSaleResult[]; } /** * POST /orders/uploadofflinesales — import counter sales as real orders. * * Each bill is committed independently, so the response reports a per-bill * outcome and a partially-good spreadsheet still imports its good bills. A * thrown error therefore means nothing at all was attempted (bad outlet, empty * batch); individual rejections come back inside `results`. * * Re-uploading the same file is safe: the backend records each bill number and * refuses one it has already imported rather than deducting the stock twice. * * `locationid` is a scope constraint, not the destination. Omit it and each * bill goes to the branch named on its own rows — the multi-branch case. Set it * and the upload is pinned to that branch, with any bill naming another one * refused; that is how a store user is held to their own store regardless of * what the spreadsheet was edited to say. */ export async function uploadOfflineSales(input: { tenantid: number; locationid?: number; userid?: number; bills: OfflineSaleBillInput[]; }): Promise { const res = await fiestaSend<{ details: OfflineSalesUploadResponse }>( 'orders/uploadofflinesales', 'POST', { tenantid: input.tenantid, locationid: input.locationid ?? 0, userid: input.userid ?? 0, bills: input.bills, }, ); return res.details; } // ════════════════════════════════════════════════════════════════════════════ // GLOBAL CATALOGUE // ════════════════════════════════════════════════════════════════════════════ /** GET /v1/web/catalogue/getbrands — List all brands + product count per brand */ export async function getGlobalBrands(): Promise { return toRows(await fiestaGet('catalogue/getbrands')); } /** GET /v1/web/catalogue/getcategories — List categories available for that brand */ export async function getGlobalCategories(opts: { brand: string }): Promise { const res = await fiestaGet<{ details: string[] }>('catalogue/getcategories', { brand: opts.brand }); return Array.isArray(res.details) ? res.details : []; } /** GET /v1/web/catalogue/getproducts — Product list */ export async function getGlobalProducts(opts: { brand: string; category?: string; keyword?: string; pageno?: number; pagesize?: number; }): Promise { return toRows(await fiestaGet('catalogue/getproducts', { brand: opts.brand, category: opts.category, keyword: opts.keyword, pageno: opts.pageno, pagesize: opts.pagesize, })); } /** GET /v1/web/catalogue/getproduct — Single product lookup by SKU */ export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise { return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku })); }