import { getEnv } from "@/env"; const ORCHESTRATION_BASE_URL = getEnv("VITE_ORCHESTRATION_API_BASE_URL"); const SESSION_STORAGE_KEY = "chatbot_user"; export interface ApiEnvelope { 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; export type RefreshResponse = ApiEnvelope; 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 { 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 | null = null; async function refreshWithStoredSession(): Promise { 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(path: string, options: RequestInit = {}, retry = true): Promise { 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(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 { return orchestrationRequest("/api/login", { method: "POST", body: JSON.stringify({ email, password }), }, false); } export async function refreshSession(refreshToken: string): Promise { return orchestrationRequest("/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 => orchestrationRequest>(`/api/v1/documents/${encodeURIComponent(userId)}`) .then((res) => res.data ?? []); export async function uploadDocument(userId: string, file: File): Promise { const form = new FormData(); form.append("user_id", userId); form.append("file", file); return orchestrationRequest("/api/v1/document/upload", { method: "POST", body: form, }); } export const processDocument = (userId: string, documentId: string) => orchestrationRequest>("/api/v1/document/process", { method: "POST", body: JSON.stringify({ user_id: userId, document_id: documentId }), }); export const deleteDocument = (userId: string, documentId: string) => orchestrationRequest>("/api/v1/document/delete", { method: "DELETE", body: JSON.stringify({ document_id: documentId, user_id: userId }), }); export const getDocumentTypes = (): Promise => orchestrationRequest>("/api/v1/documents/doctypes").then((res) => res.data ?? []); export const getDatabaseClientTypes = (): Promise => orchestrationRequest>("/api/v1/database-clients/dbtypes").then((res) => res.data ?? []); export const connectDatabase = ( userId: string, dbType: DbType, name: string, credentials: Record ): Promise => orchestrationRequest>("/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 => orchestrationRequest>(`/api/v1/database-clients/${encodeURIComponent(userId)}`) .then((res) => res.data ?? []); export const deleteDatabaseClient = (clientId: string, userId: string) => orchestrationRequest>( `/api/v1/database-clients/${encodeURIComponent(clientId)}?user_id=${encodeURIComponent(userId)}`, { method: "DELETE" } ); export const ingestDatabaseClient = (clientId: string, userId: string): Promise => orchestrationRequest>( `/api/v1/database-clients/${encodeURIComponent(clientId)}/ingest?user_id=${encodeURIComponent(userId)}`, { method: "POST" } ).then((res) => res.data); export const getDataCatalog = (userId: string): Promise => orchestrationRequest>(`/api/v1/data-catalog/${encodeURIComponent(userId)}`).then((res) => res.data); export const rebuildDataCatalog = (userId: string): Promise => orchestrationRequest>("/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>(`/api/v1/analyses${suffix}`).then((res) => normalizeAnalysisList(res.data) ); }; export const getAnalysis = (analysisId: string): Promise => orchestrationRequest>(`/api/v1/analyses/${encodeURIComponent(analysisId)}`).then((res) => res.data); export const createAnalysis = (payload: CreateAnalysisPayload): Promise => orchestrationRequest>("/api/v1/analyses", { method: "POST", body: JSON.stringify(payload), }).then((res) => res.data); export const updateAnalysis = (analysisId: string, payload: UpdateAnalysisPayload): Promise => orchestrationRequest>(`/api/v1/analyses/${encodeURIComponent(analysisId)}`, { method: "PATCH", body: JSON.stringify(payload), }).then((res) => res.data); export const deleteAnalysis = (analysisId: string): Promise => orchestrationRequest(`/api/v1/analyses/${encodeURIComponent(analysisId)}`, { method: "DELETE" }); export const updateAnalysisDataBind = ( analysisId: string, expectedVersion: number, dataBind: DataBindItem[] ): Promise => orchestrationRequest>(`/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 => orchestrationRequest>(`/api/v1/analyses/${encodeURIComponent(analysisId)}/data-catalog`).then((res) => res.data); export const rebuildAnalysisDataCatalog = (analysisId: string): Promise => orchestrationRequest>(`/api/v1/analyses/${encodeURIComponent(analysisId)}/data-catalog/rebuild`, { method: "POST", }).then((res) => res.data); export const getAnalysisMessages = (analysisId: string, limit = 100): Promise => orchestrationRequest>( `/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 => orchestrationRequest>( `/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)));