333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
import type {
|
|
ActivityResult,
|
|
AiDecision,
|
|
ApiError,
|
|
AuditEntry,
|
|
FormScreenResponse,
|
|
LoginResponse,
|
|
RecordViewParams,
|
|
RecordViewResponse,
|
|
User,
|
|
} from './types';
|
|
import { APP_ID } from './config';
|
|
|
|
const TOKEN_KEY = 'krishna_sales_token';
|
|
const USER_KEY = 'krishna_sales_user';
|
|
|
|
/**
|
|
* 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 user: User | 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);
|
|
try {
|
|
const u = localStorage.getItem(USER_KEY);
|
|
if (u) this.user = JSON.parse(u);
|
|
} catch {
|
|
this.user = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
localStorage.removeItem(USER_KEY);
|
|
this.user = null;
|
|
}
|
|
}
|
|
|
|
getToken(): string | null {
|
|
return this.token;
|
|
}
|
|
|
|
async request<T>(method: string, path: string, body?: unknown, customHeaders?: Record<string, string>): Promise<T> {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...customHeaders };
|
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
|
|
return this.send<T>(method, path, {
|
|
headers,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
// Multipart variant — no Content-Type header (the browser sets the boundary).
|
|
private async requestForm<T>(path: string, form: FormData): Promise<T> {
|
|
const headers: Record<string, string> = {};
|
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
return this.send<T>('POST', path, { headers, body: form });
|
|
}
|
|
|
|
private async send<T>(method: string, path: string, init: RequestInit): Promise<T> {
|
|
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<LoginResponse> {
|
|
const res = await this.request<LoginResponse>('POST', '/usr/login', {
|
|
email,
|
|
password,
|
|
...(orgId ? { org_id: Number(orgId) } : {}),
|
|
});
|
|
this.setToken(res.token);
|
|
if (typeof window !== 'undefined' && res.user) {
|
|
this.user = res.user;
|
|
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
|
}
|
|
return res;
|
|
}
|
|
|
|
logout(): void {
|
|
this.setToken(null);
|
|
}
|
|
|
|
/** Decode the persisted JWT into a User (no network) or use the saved user. */
|
|
currentUser(): User | null {
|
|
if (!this.token) return null;
|
|
if (this.user) return this.user;
|
|
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<RecordViewResponse> {
|
|
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
|
rv_template_uid: rvUid,
|
|
...(params.presetAlias ? { preset_alias: params.presetAlias } : {}),
|
|
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<string, unknown>;
|
|
}> {
|
|
return this.request(
|
|
'GET',
|
|
`/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`,
|
|
);
|
|
}
|
|
|
|
audit(instanceId: number | string): Promise<AuditEntry[]> {
|
|
return this.request<AuditEntry[]>(
|
|
'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<AiDecision[]> {
|
|
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<FormScreenResponse> {
|
|
return this.request<FormScreenResponse>('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<string, unknown>): Promise<ActivityResult> {
|
|
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
|
|
workflow_uuid: this.workflowUuid,
|
|
activity_id: activityUid,
|
|
data,
|
|
});
|
|
}
|
|
|
|
performActivity(
|
|
instanceId: number | string,
|
|
activityUid: string,
|
|
data: Record<string, unknown>,
|
|
): Promise<ActivityResult> {
|
|
return this.request<ActivityResult>('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<string, unknown>;
|
|
},
|
|
): Promise<{ records: Array<Record<string, unknown>> }> {
|
|
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<string, unknown>;
|
|
search?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
): Promise<{ data: Array<Record<string, unknown>>; records?: Array<Record<string, unknown>> } | Array<Record<string, unknown>>> {
|
|
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<string, unknown>;
|
|
}): Promise<{ options: Array<{ label: string; value: string; _raw?: Record<string, string> }> }> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown>; 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;
|
|
}
|