230 lines
8.1 KiB
TypeScript
230 lines
8.1 KiB
TypeScript
/**
|
|
* 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<string, QueryValue> | 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<string, QueryValue>;
|
|
body?: unknown;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
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<T>;
|
|
try {
|
|
envelope = (await response.json()) as FiestaEnvelope<T>;
|
|
} 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<T>(
|
|
path: string,
|
|
options: RequestOptions = {},
|
|
): Promise<FiestaEnvelope<T>> {
|
|
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<T>;
|
|
} catch {
|
|
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
|
|
}
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
|
|
request<T>(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: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
|
|
request<T[] | null>(path, { method: 'GET', params, signal }).then((rows) =>
|
|
Array.isArray(rows) ? rows : [],
|
|
),
|
|
|
|
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
|
request<T>(path, { method: 'POST', body, params }),
|
|
|
|
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
|
request<T>(path, { method: 'PUT', body, params }),
|
|
|
|
del: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
|
request<T>(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';
|
|
}
|