/** * 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 type { FiestaEnvelope } from './types'; /** * In dev, Vite proxies `/fiesta` -> https://fiesta.nearle.app (see * vite.config.ts), which keeps the network tab honest and sidesteps preflight * surprises. In production the deployed host is set by VITE_API_BASE. */ const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta'; /** 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 | 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; body?: unknown; signal?: AbortSignal; } async function request(path: string, options: RequestOptions = {}): Promise { 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, headers: { Accept: 'application/json' }, 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; try { envelope = (await response.json()) as FiestaEnvelope; } catch { throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, 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( path: string, options: RequestOptions = {}, ): Promise> { const { method = 'GET', params, body } = options; const url = `${API_BASE}${path}${toQueryString(params)}`; const init: RequestInit = { method, headers: { Accept: 'application/json' } }; 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; } catch { throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path); } } export const api = { get: (path: string, params?: Record, signal?: AbortSignal) => request(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: (path: string, params?: Record, signal?: AbortSignal) => request(path, { method: 'GET', params, signal }).then((rows) => Array.isArray(rows) ? rows : [], ), post: (path: string, body?: unknown, params?: Record) => request(path, { method: 'POST', body, params }), put: (path: string, body?: unknown, params?: Record) => request(path, { method: 'PUT', body, params }), del: (path: string, body?: unknown, params?: Record) => request(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'; }