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(method: string, path: string, body?: unknown): Promise { const headers: Record = { '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 { const res = await this.request('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 { return this.request('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 { const r = await this.recordView(rvUid, params); return r.data ?? r.records ?? []; } detailView(dvUid: string, instanceId: number | string): Promise { return this.request( 'GET', `/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`, ); } async audit(instanceId: number | string): Promise { const rows = await this.request( '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 { 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 { return this.request('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 { 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 = {}; 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, ): Promise { return this.request('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, ): Promise { return this.request('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; }