E2E-Frontend-Data-Eyond / src /services /orchestrationApi.ts
harryagasi
fix: handle failed AI message persistence per updated Go contract
587748b
Raw
History Blame Contribute Delete
15.2 kB
import { getEnv } from "@/env";
const ORCHESTRATION_BASE_URL = getEnv("VITE_ORCHESTRATION_API_BASE_URL");
const SESSION_STORAGE_KEY = "chatbot_user";
export interface ApiEnvelope<T> {
status?: "success" | "error" | string;
message?: string;
data: T;
}
export class ApiError extends Error {
status: number;
data: unknown;
constructor(message: string, status: number, data?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.data = data;
}
}
export interface AuthUser {
id: string;
email: string;
fullname: string;
role?: string;
status?: string;
}
export interface AuthTokens {
access_token: string;
refresh_token: string;
token_type?: string;
expires_in?: number;
refresh_expires_in?: number;
}
export interface UserSession extends AuthTokens {
user_id: string;
email: string;
name: string;
role?: string;
status?: string;
loginTime: string;
expires_at?: string;
refresh_expires_at?: string;
}
export interface LoginData extends AuthTokens {
user: AuthUser;
}
export type LoginResponse = ApiEnvelope<LoginData>;
export type RefreshResponse = ApiEnvelope<LoginData>;
export type DocumentStatus = "uploading" | "uploaded" | "processing" | "processed" | "completed" | "failed";
export interface ApiDocument {
id: string;
user_id?: string;
filename: string;
blob_name?: string;
status: DocumentStatus;
file_size: number;
file_type: string;
created_at: string;
processed_at?: string | null;
error_message?: string | null;
}
export interface UploadDocumentResponse {
status: string;
message: string;
data: ApiDocument;
}
export interface DocTypeInfo {
type: string;
max_size_mb: number;
status: "active" | "inactive";
message: string | null;
}
export type DbType = "postgres" | "mysql" | "sqlserver" | "supabase" | "bigquery" | "snowflake" | string;
export interface DbTypeField {
name: string;
type: "string" | "integer" | "select" | "boolean";
required: boolean;
default: string | number | boolean | null;
description: string;
options?: string[];
sensitive?: boolean;
}
export interface DbTypeInfo {
db_type: DbType;
display_name: string;
logo: string;
status: "active" | "inactive";
message: string | null;
fields: DbTypeField[];
}
export interface DatabaseClient {
id: string;
user_id: string;
name: string;
db_type: DbType;
status: "active" | "inactive" | string;
created_at: string;
updated_at: string | null;
}
export interface IngestColumn {
name: string;
data_type: string;
nullable: boolean;
}
export interface IngestTable {
name: string;
row_count: number;
columns: IngestColumn[];
fks: Array<{ column_name: string; foreign_table: string; foreign_column_name: string }>;
}
export interface IngestResponse {
tables: IngestTable[];
}
export interface DataCatalogSource {
source_id: string;
source_type: "schema" | "tabular" | "unstructured" | string;
name: string;
location_ref: string;
table_count?: number;
updated_at?: string;
}
export interface DataCatalog {
user_id: string;
schema_version: string;
generated_at: string;
sources: DataCatalogSource[];
}
export interface DataBindItem {
id: string;
name: string;
group_type: "document" | "database";
type: string;
}
export interface Analysis {
id: string;
user_id: string;
analysis_title: string;
objective: string;
business_questions: string[];
status: "active" | "inactive" | string;
data_bind: DataBindItem[];
data_bind_version: number;
report_collection?: unknown[];
created_at: string;
updated_at: string;
}
export interface AnalysisListResponse {
analyses: Analysis[];
pagination?: {
page: number;
limit: number;
total?: number;
total_pages?: number;
};
}
export interface AnalysisMessage {
id: string;
analysis_id: string;
user_id: string;
role: "user" | "ai";
content: string;
message_id?: string | null;
status?: "success" | "failed";
note?: string;
created_at: string;
}
export const RESERVED_FAILED_MESSAGE_ID = "00000000-0000-0000-0000-000000000000";
export interface CreateAnalysisPayload {
analysis_title: string;
objective: string;
business_questions: string[];
data_bind: DataBindItem[];
}
export interface UpdateAnalysisPayload {
analysis_title?: string;
objective?: string;
status?: "active" | "inactive";
}
function readStoredSession(): UserSession | null {
try {
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
return raw ? (JSON.parse(raw) as UserSession) : null;
} catch {
return null;
}
}
function writeStoredSession(session: UserSession): void {
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
}
function clearStoredSession(): void {
localStorage.removeItem(SESSION_STORAGE_KEY);
}
function secondsFromNow(seconds?: number): string | undefined {
if (!seconds) return undefined;
return new Date(Date.now() + seconds * 1000).toISOString();
}
function sessionFromAuthData(data: LoginData, previous?: UserSession | null): UserSession {
return {
user_id: data.user.id,
email: data.user.email,
name: data.user.fullname,
role: data.user.role,
status: data.user.status,
access_token: data.access_token,
refresh_token: data.refresh_token,
token_type: data.token_type ?? "Bearer",
expires_in: data.expires_in,
refresh_expires_in: data.refresh_expires_in,
expires_at: secondsFromNow(data.expires_in),
refresh_expires_at: secondsFromNow(data.refresh_expires_in),
loginTime: previous?.loginTime ?? new Date().toISOString(),
};
}
async function parseError(res: Response): Promise<ApiError> {
const body = await res.json().catch(() => null);
const message =
(body && typeof body === "object" && "message" in body && String((body as { message?: string }).message)) ||
(body && typeof body === "object" && "detail" in body && String((body as { detail?: string }).detail)) ||
`HTTP ${res.status}`;
return new ApiError(message, res.status, body);
}
let _refreshPromise: Promise<UserSession | null> | null = null;
async function refreshWithStoredSession(): Promise<UserSession | null> {
if (_refreshPromise) return _refreshPromise;
_refreshPromise = (async () => {
const current = readStoredSession();
if (!current?.refresh_token) return null;
const res = await fetch(`${ORCHESTRATION_BASE_URL}/api/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: current.refresh_token }),
});
if (!res.ok) {
clearStoredSession();
return null;
}
const envelope = (await res.json()) as RefreshResponse;
const next = sessionFromAuthData(envelope.data, current);
writeStoredSession(next);
return next;
})().finally(() => {
_refreshPromise = null;
});
return _refreshPromise;
}
async function orchestrationRequest<T>(path: string, options: RequestInit = {}, retry = true): Promise<T> {
const session = readStoredSession();
const headers = new Headers(options.headers);
const hasFormBody = options.body instanceof FormData;
if (!hasFormBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (session?.access_token && !headers.has("Authorization")) {
headers.set("Authorization", `${session.token_type ?? "Bearer"} ${session.access_token}`);
}
const res = await fetch(`${ORCHESTRATION_BASE_URL}${path}`, {
...options,
headers,
});
if (res.status === 401 && retry) {
const refreshed = await refreshWithStoredSession();
if (refreshed) {
return orchestrationRequest<T>(path, options, false);
}
}
if (!res.ok) throw await parseError(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export async function login(email: string, password: string): Promise<LoginResponse> {
return orchestrationRequest<LoginResponse>("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}, false);
}
export async function refreshSession(refreshToken: string): Promise<RefreshResponse> {
return orchestrationRequest<RefreshResponse>("/api/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
}, false);
}
export function persistAuthData(data: LoginData): UserSession {
const session = sessionFromAuthData(data, readStoredSession());
writeStoredSession(session);
return session;
}
export const getDocuments = (userId: string): Promise<ApiDocument[]> =>
orchestrationRequest<ApiEnvelope<ApiDocument[] | null>>(`/api/v1/documents/${encodeURIComponent(userId)}`)
.then((res) => res.data ?? []);
export async function uploadDocument(userId: string, file: File): Promise<UploadDocumentResponse> {
const form = new FormData();
form.append("user_id", userId);
form.append("file", file);
return orchestrationRequest<UploadDocumentResponse>("/api/v1/document/upload", {
method: "POST",
body: form,
});
}
export const processDocument = (userId: string, documentId: string) =>
orchestrationRequest<ApiEnvelope<{ document_id: string; file_type: string; status: string }>>("/api/v1/document/process", {
method: "POST",
body: JSON.stringify({ user_id: userId, document_id: documentId }),
});
export const deleteDocument = (userId: string, documentId: string) =>
orchestrationRequest<ApiEnvelope<unknown>>("/api/v1/document/delete", {
method: "DELETE",
body: JSON.stringify({ document_id: documentId, user_id: userId }),
});
export const getDocumentTypes = (): Promise<DocTypeInfo[]> =>
orchestrationRequest<ApiEnvelope<DocTypeInfo[]>>("/api/v1/documents/doctypes").then((res) => res.data ?? []);
export const getDatabaseClientTypes = (): Promise<DbTypeInfo[]> =>
orchestrationRequest<ApiEnvelope<DbTypeInfo[] | null>>("/api/v1/database-clients/dbtypes").then((res) => res.data ?? []);
export const connectDatabase = (
userId: string,
dbType: DbType,
name: string,
credentials: Record<string, string | number | boolean>
): Promise<DatabaseClient> =>
orchestrationRequest<ApiEnvelope<DatabaseClient>>("/api/v1/database-clients", {
method: "POST",
body: JSON.stringify({ user_id: userId, name, db_type: dbType, credentials }),
}).then((res) => res.data);
export const getDatabaseClients = (userId: string): Promise<DatabaseClient[]> =>
orchestrationRequest<ApiEnvelope<DatabaseClient[] | null>>(`/api/v1/database-clients/${encodeURIComponent(userId)}`)
.then((res) => res.data ?? []);
export const deleteDatabaseClient = (clientId: string, userId: string) =>
orchestrationRequest<ApiEnvelope<unknown>>(
`/api/v1/database-clients/${encodeURIComponent(clientId)}?user_id=${encodeURIComponent(userId)}`,
{ method: "DELETE" }
);
export const ingestDatabaseClient = (clientId: string, userId: string): Promise<IngestResponse> =>
orchestrationRequest<ApiEnvelope<IngestResponse>>(
`/api/v1/database-clients/${encodeURIComponent(clientId)}/ingest?user_id=${encodeURIComponent(userId)}`,
{ method: "POST" }
).then((res) => res.data);
export const getDataCatalog = (userId: string): Promise<DataCatalog> =>
orchestrationRequest<ApiEnvelope<DataCatalog>>(`/api/v1/data-catalog/${encodeURIComponent(userId)}`).then((res) => res.data);
export const rebuildDataCatalog = (userId: string): Promise<DataCatalog> =>
orchestrationRequest<ApiEnvelope<DataCatalog>>("/api/v1/data-catalog/rebuild", {
method: "POST",
body: JSON.stringify({ user_id: userId }),
}).then((res) => res.data);
function normalizeAnalysisList(data: AnalysisListResponse | Analysis[] | null): AnalysisListResponse {
if (!data) return { analyses: [] };
if (Array.isArray(data)) return { analyses: data };
return { analyses: data.analyses ?? [], pagination: data.pagination };
}
export const listAnalyses = (params: { status?: "active" | "inactive"; page?: number; limit?: number } = {}) => {
const query = new URLSearchParams();
if (params.status) query.set("status", params.status);
if (params.page) query.set("page", String(params.page));
if (params.limit) query.set("limit", String(params.limit));
const suffix = query.toString() ? `?${query.toString()}` : "";
return orchestrationRequest<ApiEnvelope<AnalysisListResponse | Analysis[] | null>>(`/api/v1/analyses${suffix}`).then((res) =>
normalizeAnalysisList(res.data)
);
};
export const getAnalysis = (analysisId: string): Promise<Analysis> =>
orchestrationRequest<ApiEnvelope<Analysis>>(`/api/v1/analyses/${encodeURIComponent(analysisId)}`).then((res) => res.data);
export const createAnalysis = (payload: CreateAnalysisPayload): Promise<Analysis> =>
orchestrationRequest<ApiEnvelope<Analysis>>("/api/v1/analyses", {
method: "POST",
body: JSON.stringify(payload),
}).then((res) => res.data);
export const updateAnalysis = (analysisId: string, payload: UpdateAnalysisPayload): Promise<Analysis> =>
orchestrationRequest<ApiEnvelope<Analysis>>(`/api/v1/analyses/${encodeURIComponent(analysisId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
}).then((res) => res.data);
export const deleteAnalysis = (analysisId: string): Promise<void> =>
orchestrationRequest<void>(`/api/v1/analyses/${encodeURIComponent(analysisId)}`, { method: "DELETE" });
export const updateAnalysisDataBind = (
analysisId: string,
expectedVersion: number,
dataBind: DataBindItem[]
): Promise<Analysis> =>
orchestrationRequest<ApiEnvelope<Analysis>>(`/api/v1/analyses/${encodeURIComponent(analysisId)}/data-bind`, {
method: "PUT",
body: JSON.stringify({ expected_version: expectedVersion, data_bind: dataBind }),
}).then((res) => res.data);
export const getAnalysisDataCatalog = (analysisId: string): Promise<DataCatalog> =>
orchestrationRequest<ApiEnvelope<DataCatalog>>(`/api/v1/analyses/${encodeURIComponent(analysisId)}/data-catalog`).then((res) => res.data);
export const rebuildAnalysisDataCatalog = (analysisId: string): Promise<DataCatalog> =>
orchestrationRequest<ApiEnvelope<DataCatalog>>(`/api/v1/analyses/${encodeURIComponent(analysisId)}/data-catalog/rebuild`, {
method: "POST",
}).then((res) => res.data);
export const getAnalysisMessages = (analysisId: string, limit = 100): Promise<AnalysisMessage[]> =>
orchestrationRequest<ApiEnvelope<{ messages?: AnalysisMessage[] } | AnalysisMessage[]>>(
`/api/v1/analyses/${encodeURIComponent(analysisId)}/messages?limit=${encodeURIComponent(String(limit))}`
).then((res) => (Array.isArray(res.data) ? res.data : res.data.messages ?? []));
export const createAnalysisMessage = (
analysisId: string,
payload: { role: "user" | "ai"; content: string; message_id?: string }
): Promise<AnalysisMessage> =>
orchestrationRequest<ApiEnvelope<{ message?: AnalysisMessage } | AnalysisMessage>>(
`/api/v1/analyses/${encodeURIComponent(analysisId)}/messages`,
{
method: "POST",
body: JSON.stringify(payload),
}
).then((res) => ("message" in (res.data as object) ? (res.data as { message: AnalysisMessage }).message : (res.data as AnalysisMessage)));