Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

186
src/api/client.ts Normal file
View File

@@ -0,0 +1,186 @@
/**
* 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 { demoResolve, isDemoActive, MISS } from '@/demo';
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. `/mob/*` and `/pos/*` differ. */
export const WEB = '/live/api/v1/web';
export const POS = '/live/api/v1/pos';
/**
* 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;
// Demo mode short-circuits before any network call. In a production build
// `isDemoActive` is a constant `false`, so the bundler removes this branch
// and the fixtures with it.
if (import.meta.env.DEV && isDemoActive()) {
const fixture = await demoResolve(path, params as Record<string, unknown> | undefined);
if (fixture !== MISS) {
// A beat of latency, so loading states are visible while working on them.
await new Promise((resolve) => setTimeout(resolve, 180));
return fixture as T;
}
}
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,
);
}
return envelope.details 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 }),
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';
}