import type { ActivityResult, AiDecision, ApiError, AuditEntry, FormScreenResponse, LoginResponse, RecordViewParams, RecordViewResponse, User, } from './types'; import { APP_ID } from './config'; const TOKEN_KEY = 'krishna_sales_token'; /** * HTTP client for the Zino gateway (sandbox). * * Routes are app-scoped (`/app/385/...`); login + ai-employee monitor are not. * JWT persists in localStorage so the session survives refresh. */ export class ZinoClient { readonly baseUrl: string; readonly workflowUuid: string; private token: string | null = null; private onAuthError?: () => void; constructor(baseUrl: string, workflowUuid: string, onAuthError?: () => void) { this.baseUrl = baseUrl.replace(/\/+$/, ''); this.workflowUuid = workflowUuid; 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; } async request(method: string, path: string, body?: unknown, customHeaders?: Record): Promise { const headers: Record = { 'Content-Type': 'application/json', ...customHeaders }; if (this.token) headers['Authorization'] = `Bearer ${this.token}`; return this.send(method, path, { headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); } // Multipart variant — no Content-Type header (the browser sets the boundary). private async requestForm(path: string, form: FormData): Promise { const headers: Record = {}; if (this.token) headers['Authorization'] = `Bearer ${this.token}`; return this.send('POST', path, { headers, body: form }); } private async send(method: string, path: string, init: RequestInit): Promise { const res = await fetch(`${this.baseUrl}${path}`, { method, ...init }); if (res.status === 401) { this.setToken(null); this.onAuthError?.(); throw { status: 401, message: 'Unauthorized' } as ApiError; } if (!res.ok) { let message = res.statusText; try { const j = (await res.json()) as { error?: string }; if (j.error) message = j.error; } 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 --- async login(email: string, password: string, orgId?: string): Promise { const res = await this.request('POST', '/usr/login', { email, password, ...(orgId ? { org_id: Number(orgId) } : {}), }); this.setToken(res.token); return res; } logout(): void { this.setToken(null); } /** Decode the persisted JWT into a User (no network). */ 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 --- 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', })), }, }); } detailView(dvUid: string, instanceId: number | string): Promise<{ config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> }; data: Record; }> { return this.request( 'GET', `/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`, ); } audit(instanceId: number | string): Promise { return this.request( 'GET', `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`, ); } /** AI-employee decision stream for an instance (public monitor endpoint). */ aiDecisions(instanceId: number | string): Promise { return this.request<{ decisions: AiDecision[] }>( 'GET', `/monitor/decisions?instance_id=${encodeURIComponent(String(instanceId))}`, ).then((r) => r.decisions ?? []); } // --- Form schema (view-service) --- /** Live activity form config — fields, types, options, lookup/ocr config and * the designer layout. The single source of truth for rendering a form. */ formSchema(activityId: string, instanceId?: number | string): Promise { return this.request('POST', `/app/${APP_ID}/view/form-screens`, { activity_id: activityId, device_type: 'desktop', ...(instanceId != null ? { instance_id: instanceId } : {}), }); } // --- Workflow execution (core) --- startInstance(activityUid: string, data: Record): Promise { return this.request('POST', `/app/${APP_ID}/start`, { workflow_uuid: this.workflowUuid, activity_id: activityUid, data, }); } performActivity( instanceId: number | string, activityUid: string, data: Record, ): Promise { return this.request('POST', `/app/${APP_ID}/activity`, { workflow_uuid: this.workflowUuid, instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId, activity_id: activityUid, data, }); } // --- RDBMS lookup records (owner-agent picker etc.) --- /** Search an RDBMS lookup template. Returns the matching rows. `formData` * carries the current form values so the server can apply the activity's * `field_rules` filter_options (dependent/cascading lookups, e.g. owner * agents scoped to the chosen region). */ lookupRecords( templateUid: string, opts: { activityId: string; fieldId: string; search?: string; instanceId?: number | string; limit?: number; formData?: Record; }, ): Promise<{ records: Array> }> { return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, { workflow_uuid: this.workflowUuid, activity_id: opts.activityId, field_id: opts.fieldId, instance_id: opts.instanceId || undefined, search: opts.search ?? '', limit: opts.limit ?? 50, offset: 0, form_data: opts.formData ?? {}, }); } // --- WF Lookup Records (cross-workflow references) --- /** Fetch records for a wf_lookup field. */ wfLookupRecords( opts: { activityId: string; fieldId: string; formData?: Record; search?: string; limit?: number; offset?: number; } ): Promise<{ data: Array>; records?: Array> } | Array>> { return this.request('POST', `/app/${APP_ID}/wf-lookup/records`, { workflow_uuid: this.workflowUuid, activity_id: opts.activityId, field_id: opts.fieldId, form_data: opts.formData ?? {}, search: opts.search ?? '', limit: opts.limit ?? 100, offset: opts.offset ?? 0, }); } // --- Dataset-backed select options (state/city cascade etc.) --- /** Fetch options for a dataset-backed select. The server resolves the field's * dataset + filter_options from `activityId`/`fieldId`, applying the activity's * rules against `formData` — so e.g. City options come back already scoped to * the chosen State. Returns `{label, value}` rows (plus the raw dataset row). */ datasetOptions(opts: { activityId: string; fieldId: string; search?: string; instanceId?: number | string; limit?: number; formData?: Record; }): Promise<{ options: Array<{ label: string; value: string; _raw?: Record }> }> { return this.request('POST', `/app/${APP_ID}/dataset-options`, { workflow_uuid: this.workflowUuid, activity_id: opts.activityId, field_id: opts.fieldId, instance_id: opts.instanceId || undefined, search: opts.search ?? '', limit: opts.limit ?? 200, offset: 0, form_data: opts.formData ?? {}, }); } // --- File upload + OCR (multipart, field-scoped) --- private fieldForm(file: File, ctx: FieldContext): FormData { const form = new FormData(); form.append('file', file); form.append('workflow_uuid', this.workflowUuid); form.append('activity_id', ctx.activityId); form.append('field_id', ctx.fieldId); if (ctx.instanceId != null) form.append('instance_id', String(ctx.instanceId)); return form; } /** Upload a form-field file; returns the FileMeta to store as the field value. */ uploadFile(file: File, ctx: FieldContext): Promise> { return this.requestForm(`/app/${APP_ID}/upload`, this.fieldForm(file, ctx)); } /** Run OCR on a document; `extracted` is keyed by the field's extraction keys. */ extractOcr(file: File, ctx: FieldContext): Promise<{ extracted: Record; raw?: string; parse_error?: string }> { return this.requestForm(`/app/${APP_ID}/ocr-extract`, this.fieldForm(file, ctx)); } } /** Identifiers the field-scoped CORE endpoints resolve config + RBAC from. */ export interface FieldContext { activityId: string; fieldId: string; instanceId?: number | string; }