HDFC-Loan-Desk/src/api/client.ts

306 lines
10 KiB
TypeScript

import type {
ActivityResult,
AiDecision,
ApiError,
AuditEntry,
DetailViewResponse,
FormScreenResponse,
LoginResponse,
OcrResult,
RecordViewParams,
RecordViewResponse,
Row,
User,
} from './types';
import { APP_ID, WORKFLOW } from './config';
const TOKEN_KEY = 'hdfc_loan_desk_token';
/**
* HTTP client for the Zino gateway.
*
* Most routes are app-scoped (`/app/524/...`); login and the AI-employee
* monitor are not. The JWT persists in localStorage so a refresh does not
* bounce the operator back to the login screen mid-disruption.
*/
export class ZinoClient {
readonly baseUrl: string;
private token: string | null = null;
private onAuthError?: () => void;
constructor(baseUrl: string, onAuthError?: () => void) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.onAuthError = onAuthError;
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY);
}
setAuthErrorHandler(fn: () => void): void {
this.onAuthError = fn;
}
setToken(token: string | null): void {
this.token = token;
if (typeof window === 'undefined') return;
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
getToken(): string | null {
return this.token;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
this.setToken(null);
this.onAuthError?.();
throw { status: 401, message: 'Session expired — sign in again' } as ApiError;
}
if (!res.ok) {
let message = res.statusText;
try {
const j = (await res.json()) as { error?: string; message?: string };
message = j.error || j.message || message;
} catch {
/* non-JSON body */
}
throw { status: res.status, message } as ApiError;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// --- Auth ---------------------------------------------------------------
/** org_id must be a STRING. The gateway rejects a number with
* "cannot unmarshal number into Go struct field LoginRequest.org_id". */
async login(email: string, password: string, orgId: string): Promise<LoginResponse> {
const res = await this.request<LoginResponse>('POST', '/usr/login', {
email,
password,
org_id: String(orgId),
});
this.setToken(res.token);
return res;
}
logout(): void {
this.setToken(null);
}
/** Decode the persisted JWT into a User — no network round trip, so a hard
* refresh restores the session without flashing the login screen. */
currentUser(): User | null {
if (!this.token) return null;
try {
const p = JSON.parse(atob(this.token.split('.')[1]));
return {
id: String(p.user_id ?? p.sub ?? ''),
org_id: String(p.org_id ?? ''),
name: p.name ?? '',
email: p.email ?? '',
roles: p.roles ?? [],
groups: p.groups ?? [],
};
} catch {
return null;
}
}
// --- Views --------------------------------------------------------------
async recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvUid,
search_query: {
page: params.page ?? 1,
limit: params.limit ?? 50,
sort_by: params.sortBy ?? '',
sort_dir: params.sortDir ?? 'desc',
search: params.search ?? '',
filters: (params.filters ?? []).map((f) => ({
field_key: f.field_key,
value: f.value,
value2: '',
data_type: f.data_type ?? 'string',
})),
},
});
}
/** Rows only. The response key differs by source type — workflow views
* return `data`, rdbms views have been seen returning `records`. */
async rows(rvUid: string, params: RecordViewParams = {}): Promise<Row[]> {
const r = await this.recordView(rvUid, params);
return r.data ?? r.records ?? [];
}
detailView(dvUid: string, instanceId: number | string): Promise<DetailViewResponse> {
return this.request<DetailViewResponse>(
'GET',
`/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`,
);
}
async audit(instanceId: number | string): Promise<AuditEntry[]> {
const rows = await this.request<AuditEntry[]>(
'GET',
`/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`,
);
// One SUBMISSION writes several audit rows: a dispatch record, a
// trigger-pipeline record, and finally the commit (whose
// execution_state is the state id, and which carries the badge). The
// history screen was rendering all of them, so every activity appeared
// three times with identical text. Only the commit row is the event a
// person means by "what happened"; the rest are the engine's own
// bookkeeping, kept in the DB for reconciliation and deliberately not
// shown. Filtered by prefix because the set of plumbing constants has
// grown before (TRIGGER_DISPATCHED_POST_COMMIT is historical-only).
return (rows ?? []).filter((r) => !String(r.execution_state ?? '').startsWith('TRIGGER_'));
}
/** AI-employee decisions for an instance. Public monitor route. */
aiDecisions(instanceId: number | string): Promise<AiDecision[]> {
return this.request<{ decisions: AiDecision[] }>(
'GET',
`/monitor/decisions?instance_id=${encodeURIComponent(String(instanceId))}`,
).then((r) => r.decisions ?? []);
}
// --- Forms --------------------------------------------------------------
/** The live definition of an activity's form: fields, types, select options
* and which are mandatory. Read at open time rather than hardcoded, so a
* field added in Studio appears here without a frontend change. */
formSchema(activityUid: string, instanceId?: number | string): Promise<FormScreenResponse> {
return this.request<FormScreenResponse>('POST', `/app/${APP_ID}/view/form-screens`, {
activity_id: activityUid,
device_type: 'desktop',
...(instanceId != null ? { instance_id: numericId(instanceId) } : {}),
});
}
// --- Document intelligence ----------------------------------------------
/**
* Run OCR on an uploaded document for an `ocr` field.
*
* The extraction contract is NOT sent from here. The endpoint resolves the
* field's `ocr_config` server-side from the deployed workflow config and
* ignores anything a client supplies — so which values come back is decided
* by the field's configuration, and the response keys are exactly that
* config's `extraction_fields[].key`.
*
* `instanceId` is omitted on an INIT activity, where no instance exists yet.
*
* This deliberately bypasses `request()`: it must NOT set Content-Type. The
* browser has to write `multipart/form-data; boundary=…` itself, and setting
* that header by hand omits the boundary, after which the server rejects the
* body as malformed multipart.
*/
async ocrExtract(
activityUid: string,
fieldId: string,
file: File,
instanceId?: number | string,
): Promise<OcrResult> {
const fd = new FormData();
fd.append('file', file);
fd.append('workflow_uuid', WORKFLOW);
fd.append('activity_id', activityUid);
fd.append('field_id', fieldId);
if (instanceId != null && instanceId !== '') {
fd.append('instance_id', String(numericId(instanceId)));
}
const headers: Record<string, string> = {};
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
const res = await fetch(`${this.baseUrl}/app/${APP_ID}/ocr-extract`, {
method: 'POST',
headers,
body: fd,
});
if (res.status === 401) {
this.setToken(null);
this.onAuthError?.();
throw { status: 401, message: 'Session expired — sign in again' } as ApiError;
}
if (!res.ok) {
let message = res.statusText;
try {
const j = (await res.json()) as { error?: string; message?: string };
message = j.error || j.message || message;
} catch {
/* non-JSON body */
}
throw { status: res.status, message } as ApiError;
}
return (await res.json()) as OcrResult;
}
// --- Workflow execution -------------------------------------------------
/** Start a new instance (an INIT activity such as Register Flight). */
startInstance(
workflowUuid: string,
activityUid: string,
data: Record<string, unknown>,
): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
workflow_uuid: workflowUuid,
activity_id: activityUid,
data,
});
}
/** Perform an activity on an existing instance. The workflow refuses
* anything the current state does not allow — that refusal is a feature,
* not an error to paper over. */
performActivity(
workflowUuid: string,
instanceId: number | string,
activityUid: string,
data: Record<string, unknown>,
): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
workflow_uuid: workflowUuid,
instance_id: numericId(instanceId),
activity_id: activityUid,
data,
});
}
}
/**
* Instance ids must go over the wire as JSON NUMBERS.
*
* The route params they come from are strings, and view-service decodes
* `instance_id` into an int64 — so a quoted "9765" fails the decode and the
* whole request comes back "Invalid request body" with no clue which field
* was at fault. That is what broke Retry call: the form schema never loaded,
* because the id had been carried straight through from useParams.
*
* performActivity had its own inline coercion and formSchema did not, which
* is exactly the kind of divergence that survives review. One helper, used by
* both.
*
* A non-numeric id is passed through untouched rather than becoming NaN — if
* an id ever stops being an integer, the server should say so plainly instead
* of receiving null.
*/
function numericId(id: number | string): number | string {
if (typeof id === 'number') return id;
const n = Number(id);
return Number.isFinite(n) && id.trim() !== '' ? n : id;
}