zurich_kotak/src/api/client.js
Yashas 43259b378c fix(upload): always send workflow_uuid
/upload requires it and it is fixed for this app, but the client only
appended it when ctx happened to carry it — and FileField passes only
activity, field and instance. Every upload came back 400 with
"Missing required params: workflow_uuid".

Taken from config now, so it cannot be forgotten by a caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:46:51 +05:30

237 lines
7.7 KiB
JavaScript

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()
}
/**
* 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))}`)
}
}