/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>
237 lines
7.7 KiB
JavaScript
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))}`)
|
|
}
|
|
}
|