krishna_sales/src/api/client.ts
2026-07-20 13:31:26 +05:30

312 lines
10 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';
/**
* 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<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);
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<RecordViewResponse> {
return this.request<RecordViewResponse>('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<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;
}