306 lines
10 KiB
TypeScript
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;
|
|
}
|