import { APP_ID } from "../config"; import { resolveConfigValue, basePath } from "../runtimeConfig"; const TOKEN_KEY = "p2p_token"; function baseUrl(): string { return resolveConfigValue("VITE_ZINO_API_URL", "https://studio.getzino.in").replace(/\/+$/, ""); } function headers(): Record { const h: Record = { "Content-Type": "application/json" }; const token = localStorage.getItem(TOKEN_KEY); if (token) h["Authorization"] = `Bearer ${token}`; return h; } // Shared response handling — 401 bounces to login, error bodies are unwrapped // from { error }. Used by both the JSON and multipart request paths. async function parseResponse(res: Response): Promise { if (res.status === 401) { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem("p2p_user"); window.location.href = `${basePath()}login`; throw new Error("Unauthorized"); } if (!res.ok) { let msg = res.statusText; try { const e = await res.json(); if (e.error) msg = e.error; } catch {} // Status rides along so callers can special-case it (403 -> no permission). throw Object.assign(new Error(msg), { status: res.status }); } if (res.status === 204) return undefined as T; return res.json() as Promise; } async function request(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${baseUrl()}${path}`, { method, headers: headers(), body: body ? JSON.stringify(body) : undefined, }); return parseResponse(res); } // Multipart POST for the field-scoped core-service endpoints (/upload, // /ocr-extract). Content-Type is deliberately omitted so the browser sets the // multipart boundary itself. async function postFile(path: string, file: File, ctx: FieldFileContext): Promise { const form = new FormData(); form.append("file", file); form.append("workflow_uuid", ctx.workflowUuid); form.append("activity_id", ctx.activityId); form.append("field_id", ctx.fieldId); if (ctx.columnId) form.append("column_id", ctx.columnId); if (ctx.versionUuid) form.append("version_uuid", ctx.versionUuid); if (ctx.instanceId) form.append("instance_id", String(ctx.instanceId)); const token = localStorage.getItem(TOKEN_KEY); const res = await fetch(`${baseUrl()}${path}`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: form, }); return parseResponse(res); } // --------------------------------------------------------------------------- // Header // --------------------------------------------------------------------------- export interface HeaderConfig { slots: { left: string[]; right: string[]; center: string[] }; layout: { type: string; height: string; position: string; full_width: boolean }; components: { nav?: { items: NavItem[] }; logo?: { text?: string; image_url?: string; display_type: string }; profile?: { shape: string; show_name: boolean }; }; } export interface NavItem { id: string; icon: string; type: string; label: string; screen_uuid: string; } export async function getHeaderConfig(deviceType = "desktop"): Promise { const raw = await request("GET", `/app/${APP_ID}/view/header/${deviceType}`); return raw.config ?? raw; } // --------------------------------------------------------------------------- // Screens // --------------------------------------------------------------------------- export interface ScreenListItem { id: number; source_screen_id: number; screen_name: string; device: string; description: string; } export interface ScreenLayout { id: number; source_screen_id: number; screen_name: string; layout: LayoutElement[]; } export interface LayoutElement { type: string; style?: Record; config: Record; classes?: string[]; } export function getScreens(deviceType = "desktop"): Promise { return request("GET", `/app/${APP_ID}/view/screens?device_type=${deviceType}`); } export function getScreen(idOrUuid: number | string): Promise { return request("GET", `/app/${APP_ID}/view/screens/${idOrUuid}`); } // --------------------------------------------------------------------------- // RV Screens // --------------------------------------------------------------------------- export interface RVScreen { id: number; source_screen_id: number; screen_name: string; rv_template_uid: string; layout?: any; } // Accepts either the numeric source_screen_id OR the source_uuid — both // are accepted by the view-service's rv-screens endpoint. export function getRVScreen(idOrUuid: number | string): Promise { return request("GET", `/app/${APP_ID}/view/rv-screens/${idOrUuid}`); } // --------------------------------------------------------------------------- // Record View (POST-based) // --------------------------------------------------------------------------- export interface SearchQuery { page?: number; limit?: number; sort_by?: string; sort_dir?: "asc" | "desc"; search?: string; filters?: Array<{ field_key: string; value: string; data_type?: string }>; } export interface RecordViewField { field_key: string; output_label: string; data_type: string; is_filter: boolean; is_search: boolean; type?: string; activity_id?: string; } export interface TileValue { tile_uid: string; key: string; value: unknown; } export interface ChartDataRow { dimension: unknown; series?: string; value: unknown; } export interface ChartDataResponse { chart_uid: string; key: string; rows: ChartDataRow[]; } export interface RecordViewResponse { config: { fields: RecordViewField[] }; data: Record[]; pagination?: { page: number; limit: number; total_count: number; total_pages: number; }; tile_values?: TileValue[]; chart_data?: ChartDataResponse[]; } export function getRecordView( rvTemplateUid: string, rvScreenId: string | number, query: SearchQuery = {}, ): Promise { return request("POST", `/app/${APP_ID}/view/recordview`, { rv_template_uid: rvTemplateUid, rv_screen_id: String(rvScreenId), search_query: { page: query.page || 1, limit: query.limit || 50, sort_by: query.sort_by || "", sort_dir: query.sort_dir || "desc", search: query.search || "", filters: query.filters || [], }, }); } export function getRecordViewLegacy( rvUid: string, query: SearchQuery = {}, ): Promise { const qs = new URLSearchParams(); qs.set("rv_id", rvUid); if (query.page) qs.set("page", String(query.page)); if (query.limit) qs.set("limit", String(query.limit)); if (query.sort_by) qs.set("sort_by", query.sort_by); if (query.sort_dir) qs.set("sort_dir", query.sort_dir); if (query.search) qs.set("search", query.search); if (query.filters) { for (const f of query.filters) { qs.set(`filter.${f.field_key}`, f.value); } } return request("GET", `/recordview?${qs.toString()}`); } // --------------------------------------------------------------------------- // DV Screens // --------------------------------------------------------------------------- export interface DVScreen { id: number; source_screen_id: number; screen_name: string; dv_template_uid: string; layout?: any; } export function getDVScreen(idOrUuid: number | string): Promise { return request("GET", `/app/${APP_ID}/view/dv-screens/${idOrUuid}`); } // --------------------------------------------------------------------------- // Detail View // --------------------------------------------------------------------------- export interface DetailViewResponse { config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> }; data: Record; } export function getDetailView(dvSourceIdOrUid: number | string, instanceId: string): Promise { return request( "GET", `/app/${APP_ID}/view/detailview/${encodeURIComponent(String(dvSourceIdOrUid))}?instance_id=${encodeURIComponent(instanceId)}`, ); } export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise { return request( "GET", `/detailview?dv_id=${encodeURIComponent(dvUid)}&instance_id=${encodeURIComponent(instanceId)}`, ); } // --------------------------------------------------------------------------- // Form Screens // --------------------------------------------------------------------------- // One key the vision model must return, as configured in studio's OCR modal. export interface OcrExtractionField { key: string; label: string; data_type?: string; description?: string; } // extraction_key → the form field that receives the extracted value. export interface OcrFieldMapping { extraction_key: string; target_field: string; } // Studio's ocr_config block. `prompt` / `extraction_fields` are resolved // server-side by /ocr-extract and are present here only for picker UX; the // client genuinely needs `field_mappings` to know where to write the results. export interface OcrConfig { prompt?: string; template?: string; allowed_types?: string[] | string; max_size_mb?: number | string; placeholder?: string; extraction_fields?: OcrExtractionField[]; field_mappings?: OcrFieldMapping[]; } export interface FormScreenField { id: string; uid: string; name: string; type: string; data_type: string; mandatory: boolean; value?: any; properties?: { options?: Array<{ label: string; value: string }>; country_code?: string; placeholder?: string; multiple?: boolean; ocr_config?: OcrConfig; // Some studio configs put the OCR keys flat on properties instead of // nested under ocr_config — mirrored from FFOcrField's resolveOcrConfig. allowed_types?: string[] | string; max_size_mb?: number | string; extraction_fields?: OcrExtractionField[]; field_mappings?: OcrFieldMapping[]; }; } export interface FormScreenResponse { id: number; activity_uid: string; activity_name: string; device_type: string; fields: FormScreenField[]; grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>; layout: any[]; // Identifiers the field-scoped core endpoints (/upload, /ocr-extract) need // so they can resolve the field's config + RBAC server-side. workflow_uuid?: string; version_uuid?: string; } export function getFormScreen( activityId: string, instanceId?: string, deviceType = "desktop", ): Promise { const numericInstanceId = instanceId ? Number(instanceId) : undefined; return request("POST", `/app/${APP_ID}/view/form-screens`, { activity_id: activityId, device_type: deviceType, ...(numericInstanceId && !Number.isNaN(numericInstanceId) ? { instance_id: numericInstanceId } : {}), }); } // --------------------------------------------------------------------------- // Field files & OCR (core-service) // --------------------------------------------------------------------------- // Identifiers sent alongside every field-scoped file upload. The backend uses // these to resolve the field's deployed allowed_types / max_size_mb / ocr_config // and to run activity RBAC — anything the client sends about that config is // ignored, so this trio is mandatory. export interface FieldFileContext { workflowUuid: string; activityId: string; fieldId: string; columnId?: string; versionUuid?: string; instanceId?: string; } // What /upload returns and what gets stored in instance data. `uuid` is the // stable app-scoped handle; blob_path is kept for the legacy serve fallback. export interface FileMeta { uuid: string; blob_path: string; original_name: string; mime_type: string; size_bytes: number; } export interface OcrExtractResponse { // Keyed by the studio-configured extraction_fields[].key. A key whose value // wasn't found in the document comes back as null. extracted: Record; raw?: string; // Set when the model's reply wasn't parseable JSON — `raw` still holds the text. parse_error?: string; } // Stores a form-field file and returns the metadata to submit in its place. export function uploadFieldFile(file: File, ctx: FieldFileContext): Promise { return postFile(`/app/${APP_ID}/upload`, file, ctx); } // Runs vision OCR over an uploaded document. The prompt and extraction schema // live in the deployed ocr_config, so only the file + identifiers go over the wire. export function extractOcr(file: File, ctx: FieldFileContext): Promise { return postFile(`/app/${APP_ID}/ocr-extract`, file, ctx); } // --------------------------------------------------------------------------- // Workflow Actions // --------------------------------------------------------------------------- export interface WorkflowResponse { success: boolean; status_code?: number; message?: string; data?: Record; instance_id?: string; } // Core-service expects the workflow UUID as `workflow_uuid` — the numeric // `workflow_id` field was removed when the clone-portable identifier rolled out. export function startWorkflow( workflowUuid: string, activityId: string, data?: Record, ): Promise { return request("POST", `/app/${APP_ID}/start`, { workflow_uuid: workflowUuid, activity_id: activityId, data, }); } export function performActivity( workflowUuid: string, instanceId: string, activityId: string, data?: Record, ): Promise { const numericInstanceId = Number(instanceId); return request("POST", `/app/${APP_ID}/activity`, { workflow_uuid: workflowUuid, instance_id: Number.isNaN(numericInstanceId) ? instanceId : numericInstanceId, activity_id: activityId, data, }); } export function submitForm( workflowUuid: string, activityId: string, formData: Record, instanceId?: string, deviceType = "desktop", ): Promise { const numericInstanceId = instanceId ? Number(instanceId) : undefined; return request("POST", `/app/${APP_ID}/form/submit`, { workflow_uuid: workflowUuid, activity_id: activityId, device_type: deviceType, form_data: formData, ...(numericInstanceId && !Number.isNaN(numericInstanceId) ? { instance_id: numericInstanceId } : {}), }); } // --------------------------------------------------------------------------- // Instance // --------------------------------------------------------------------------- export interface InstanceResponse { instance_id: string; workflow_id: string; current_state_id: string; current_state_name: string; data: Record; created_at: string; updated_at: string; } export function getInstance(workflowUuid: string, instanceId: string): Promise { return request("POST", `/app/${APP_ID}/instance`, { workflow_uuid: workflowUuid, instance_id: Number(instanceId), }); } // --------------------------------------------------------------------------- // Audit // --------------------------------------------------------------------------- export interface AuditEntry { id: number; user_id: string; user_roles: string[]; user_groups?: string[]; activity_id: string; data: Record; /** State UUID the instance landed in after this activity. */ execution_state: string; created_at: string; // Optional human-readable summary line. Populated by useInstanceMeta when // the audit is synthesised from instance.data._activities. context?: string; } export function getAuditLog(instanceId: string): Promise { return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`); } // --------------------------------------------------------------------------- // AI Employee monitoring (ai-employee-service) // --------------------------------------------------------------------------- // These are the same endpoints the studio builder's "AI Employee → Logs" tab // uses. They are NOT app-scoped: the ingress maps studio.getzino.in/monitor // straight to ai-employee-service:8090, so the path has no /app/{id} prefix. // The app's own JWT is accepted. /** One reasoning pass the employee took, as stored in aiemployee.tbl_ai_decisions. */ export interface AIDecision { id: number; ai_user_id: string; instance_id: string; /** The activity the employee SUBMITTED (its chosen next step), not the one it read. */ activity_id: string; /** State the instance was in when the employee looked at it. */ instance_state?: string; status: "submitted" | "failed" | "error" | "escalated" | "submitting" | string; confidence?: number; model?: string; created_at: string; /** Opening plan the model wrote before acting. */ plan?: string; /** Why it chose this action. */ reasoning?: string; /** Self-check pass. */ reflexion?: { concerns?: string; should_escalate?: boolean; adjusted_confidence?: number; } | null; knowledge_sources?: string[] | null; /** The payload it submitted with the activity. */ data?: Record; error_message?: string; cost?: number; llm_calls?: number; duration_ms?: number; } export interface AIEscalation { id: number; instance_id: string; recommended_action?: string; reasoning?: string; reason?: string; status?: string; created_at: string; } export interface AIInstanceLog { instance_id: string; decisions: AIDecision[]; escalations: AIEscalation[]; } /** One step inside a run — a tool call, an LLM call, a context gather, etc. */ export interface AITraceEvent { seq: number; parent_seq?: number; type: string; name: string; status: string; started_at: string; ended_at?: string; duration_ms?: number; input?: unknown; output?: unknown; tokens_in?: number; tokens_out?: number; cost?: number; error?: string; decision_id?: number; } /** One task the employee processed end-to-end. */ export interface AITrace { trace_id: string; events: AITraceEvent[]; } export interface AIInstanceTrace { instance_id: string; traces: AITrace[]; } export function getAIInstanceLog(instanceId: string): Promise { return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/log`); } export function getAIInstanceTrace(instanceId: string): Promise { return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/trace`); } // --------------------------------------------------------------------------- // Field-file download // --------------------------------------------------------------------------- /** * Fetches a stored form-field file (e.g. the uploaded invoice PDF) as a Blob. * `blobPath` comes from the FileMeta the upload returned and is echoed back in * the activity's submitted data. Returns a Blob so the caller can either build * an object URL for inline preview or trigger a download — the endpoint sets * Content-Disposition: attachment, so a plain link would always download. */ export async function downloadFieldFile(blobPath: string, originalName?: string): Promise { const params = new URLSearchParams({ path: blobPath }); if (originalName) params.set("name", originalName); const token = localStorage.getItem(TOKEN_KEY); const res = await fetch(`${baseUrl()}/app/${APP_ID}/download?${params.toString()}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) { if (res.status === 401) { localStorage.removeItem(TOKEN_KEY); window.location.href = `${basePath()}login`; } throw new Error(`Could not load the document (${res.status})`); } return res.blob(); } // --------------------------------------------------------------------------- // RDBMS lookup records — used by the demo calendar to pull rows from the // `mahindra_demo` schema via the lookup-field config on the INIT activity. // --------------------------------------------------------------------------- export interface RdbmsLookupResponse { records: Record[]; total: number; limit: number; offset: number; } export interface RdbmsLookupQuery { workflowId: number; activityId: string; fieldId: string; limit?: number; offset?: number; search?: string; } export function fetchRdbmsLookupRecords(templateUuid: string, q: RdbmsLookupQuery): Promise { return request("POST", `/app/${APP_ID}/rdbms-templates/${encodeURIComponent(templateUuid)}/records`, { workflow_id: q.workflowId, activity_id: q.activityId, field_id: q.fieldId, limit: q.limit ?? 1000, offset: q.offset ?? 0, search: q.search ?? "", }); }