import { APP_ID, WORKFLOW } from './config' const TOKEN_KEY = 'zk_lead_desk_token' const USER_KEY = 'zk_lead_desk_user' /** * HTTP client for the Zino gateway. * * Most routes are app-scoped (`/app/536/...`); login is not. The JWT persists * in localStorage so a refresh does not bounce the operator back to the login * screen mid-review. */ export class ZinoClient { constructor(baseUrl, onAuthError) { this.baseUrl = String(baseUrl).replace(/\/+$/, '') this.token = null this.onAuthError = onAuthError if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY) } setAuthErrorHandler(fn) { this.onAuthError = fn } setToken(token) { this.token = token if (typeof window === 'undefined') return if (token) localStorage.setItem(TOKEN_KEY, token) else localStorage.removeItem(TOKEN_KEY) } getToken() { return this.token } setStoredUser(user) { if (typeof window === 'undefined') return if (user) localStorage.setItem(USER_KEY, JSON.stringify(user)) else localStorage.removeItem(USER_KEY) } getStoredUser() { if (typeof window === 'undefined') return null try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null') } catch { return null } } async request(method, path, body) { const headers = { '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.setStoredUser(null) this.onAuthError?.() throw { status: 401, message: 'Session expired — sign in again' } } if (!res.ok) { let message = res.statusText try { const j = await res.json() message = j.error || j.message || message } catch { /* non-JSON body */ } throw { status: res.status, message } } if (res.status === 204) return undefined return res.json() } /* ── The support desk ──────────────────────────────────────────────────── Not app-scoped: these are the platform's own agent routes, and the token this console already holds is accepted on them as-is. A conversation is a session; a question is a message on it. */ /** This operator's conversations with the desk, newest first. */ deskSessions(agentId) { return this.request('GET', `/api/agent-sessions?agent_id=${agentId}`) } /** Open a new conversation. */ deskStartSession(agentId) { return this.request('POST', '/api/agent-sessions', { agent_id: agentId }) } /** The transcript of one conversation. */ deskMessages(sessionId) { return this.request('GET', `/api/agent-sessions/${sessionId}/messages`) } /** * Ask. Answers with the rows the backend persisted for the turn — * { user_message, call_api_message, agent_message } — not a bare string. * * These take twenty to forty seconds: the desk runs real queries against the * book before it answers. The caller has to say so, or the panel looks hung. */ deskAsk(sessionId, content) { return this.request('POST', `/api/agent-sessions/${sessionId}/messages`, { content }) } /** * 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, password, orgId) { const res = await this.request('POST', '/usr/login', { email, password, org_id: String(orgId), }) if (res?.token) { this.setToken(res.token) this.setStoredUser(res.user || null) } return res } logout() { this.setToken(null) this.setStoredUser(null) } /** * Paginated records for a record view, filtered server-side. * * The POST body is NOT the same shape as the GET query params: the view is * named by `rv_template_uid` (not `rv_id`, which is the GET spelling), and * paging/sort/filters all live INSIDE `search_query`. Sending them at the top * level returns `400 Missing param: rv_template_uid (body)`. */ recordView(rvUid, params = {}) { 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.sort_by ?? '', sort_dir: params.sort_dir ?? '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, params = {}) { const r = await this.recordView(rvUid, params) return r?.data ?? r?.records ?? r?.rows ?? [] } detailView(dvUid, instanceId) { return this.request( 'GET', `/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`, ) } /** * The LIVE definition of an activity's form: fields, types, select options, * which are mandatory. Read rather than hardcoded so that adding a field in * Studio and redeploying surfaces it here with no frontend change — the * workflow stays the source of truth. */ formSchema(activityUid, instanceId) { return this.request('POST', `/app/${APP_ID}/view/form-screens`, { activity_id: activityUid, device_type: 'desktop', ...(instanceId ? { instance_id: instanceId } : {}), }) } /** Start a new instance via one of the three INIT activities. */ start(activityUid, data) { return this.request('POST', `/app/${APP_ID}/start`, { workflow_uuid: WORKFLOW, activity_id: activityUid, data, }) } /** * Perform an activity on an existing instance. The workflow refuses what the * signed-in user may not do — a permission denial arrives as 403 here and as * 400 on /start, both carrying "permission denied". */ activity(instanceId, activityUid, data) { return this.request('POST', `/app/${APP_ID}/activity`, { workflow_uuid: WORKFLOW, instance_id: instanceId, activity_id: activityUid, data, }) } /** * Upload a file and get back a reference — {uuid, blob_path, original_name, * mime_type}. The field context matters: the backend resolves the field's * own config (allowed types, size limit, ocr_config) server-side from it and * ignores anything the client claims. */ async uploadFile(file, ctx) { const form = new FormData() form.append('file', file) // workflow_uuid is REQUIRED and is fixed for this app, so it is taken from // config rather than from ctx. Passing it optionally is how the first // version shipped: the upload sent activity_id, field_id and instance_id // and got back `Missing required params: workflow_uuid`. form.append('workflow_uuid', WORKFLOW) if (ctx.activityId) form.append('activity_id', ctx.activityId) if (ctx.fieldId) form.append('field_id', ctx.fieldId) if (ctx.instanceId) form.append('instance_id', String(ctx.instanceId)) const headers = {} if (this.token) headers['Authorization'] = `Bearer ${this.token}` const res = await fetch(`${this.baseUrl}/app/${APP_ID}/upload`, { method: 'POST', headers, body: form }) if (!res.ok) { let message = res.statusText try { const j = await res.json(); message = j.error || j.message || message } catch { /* non-JSON */ } throw { status: res.status, message } } return res.json() } /** * Extract from an ALREADY-UPLOADED file. Sends a reference, not the bytes — * the document crosses the wire once, survives a reload, and re-extracting * costs no re-upload. The ocr_config (which fields to pull, where they map) * is resolved server-side from the deployed workflow; anything the client * sends is ignored. */ ocrExtract(fileRef, ctx) { return this.request('POST', `/app/${APP_ID}/ocr-extract`, { workflow_uuid: WORKFLOW, activity_id: ctx.activityId, field_id: ctx.fieldId, instance_id: ctx.instanceId || undefined, files: [fileRef], }) } instance(instanceId) { return this.request('POST', `/app/${APP_ID}/instance`, { workflow_uuid: WORKFLOW, instance_id: instanceId, }) } /** * The instance's audit trail: one entry per activity performed, with WHO * performed it and the data they wrote. * * Must be the APP-SCOPED path. The bare `/view/audit` is not an API route at * all — it falls through to the SPA and returns HTML with a 200, which * parses as a JSON error rather than an HTTP one. */ audit(instanceId) { return this.request('GET', `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`) } }