p2p/src/api/viewService.ts
2026-09-04 13:15:31 +05:30

664 lines
20 KiB
TypeScript

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<string, string> {
const h: Record<string, string> = { "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<T>(res: Response): Promise<T> {
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<T>;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${baseUrl()}${path}`, {
method,
headers: headers(),
body: body ? JSON.stringify(body) : undefined,
});
return parseResponse<T>(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<T>(path: string, file: File, ctx: FieldFileContext): Promise<T> {
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<T>(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<HeaderConfig> {
const raw = await request<any>("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<string, string>;
config: Record<string, any>;
classes?: string[];
}
export function getScreens(deviceType = "desktop"): Promise<ScreenListItem[]> {
return request("GET", `/app/${APP_ID}/view/screens?device_type=${deviceType}`);
}
export function getScreen(idOrUuid: number | string): Promise<ScreenLayout> {
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<RVScreen> {
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<string, unknown>[];
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<RecordViewResponse> {
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<RecordViewResponse> {
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<DVScreen> {
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<string, unknown>;
}
export function getDetailView(dvSourceIdOrUid: number | string, instanceId: string): Promise<DetailViewResponse> {
return request(
"GET",
`/app/${APP_ID}/view/detailview/${encodeURIComponent(String(dvSourceIdOrUid))}?instance_id=${encodeURIComponent(instanceId)}`,
);
}
export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise<DetailViewResponse> {
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<FormScreenResponse> {
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<string, unknown>;
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<FileMeta> {
return postFile<FileMeta>(`/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<OcrExtractResponse> {
return postFile<OcrExtractResponse>(`/app/${APP_ID}/ocr-extract`, file, ctx);
}
// ---------------------------------------------------------------------------
// Workflow Actions
// ---------------------------------------------------------------------------
export interface WorkflowResponse {
success: boolean;
status_code?: number;
message?: string;
data?: Record<string, unknown>;
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<string, unknown>,
): Promise<WorkflowResponse> {
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<string, unknown>,
): Promise<WorkflowResponse> {
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<string, unknown>,
instanceId?: string,
deviceType = "desktop",
): Promise<WorkflowResponse> {
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<string, unknown>;
created_at: string;
updated_at: string;
}
export function getInstance(workflowUuid: string, instanceId: string): Promise<InstanceResponse> {
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<string, unknown>;
/** 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<AuditEntry[]> {
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<string, unknown>;
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<AIInstanceLog> {
return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/log`);
}
export function getAIInstanceTrace(instanceId: string): Promise<AIInstanceTrace> {
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<Blob> {
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<string, unknown>[];
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<RdbmsLookupResponse> {
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 ?? "",
});
}