// API Service Layer for OpenWA Dashboard // Centralized API client with TypeScript types import { warnIfInsecureHttpUrl } from '../utils/urlSecurity'; // Resolve the API base URL. By default this is the same-origin relative path '/api', // correct when the dashboard and API are served from the same origin (the default // single-container setup). For a split-origin deployment (dashboard hosted separately // from the API), set VITE_API_URL at build time to the API ORIGIN — e.g. // `VITE_API_URL=https://gateway.example.com` — and the '/api' prefix is appended here. // Previously VITE_API_URL was documented but never read, so the dashboard always called // same-origin '/api' and a split deployment failed with "Invalid API Key" (#91). // Exported so direct fetches (e.g. auth/validate in Login.tsx / App.tsx) honor VITE_API_URL // too — otherwise split-origin deployments break. Empty VITE_API_URL → '/api'. const API_ORIGIN = (import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, ''); export const API_BASE_URL = `${API_ORIGIN}/api`; // Warn (not refuse — would break dev + TLS-terminating-proxy) when the API origin is an // insecure http:// URL pointing at a non-localhost host (API keys sent in cleartext). if (API_ORIGIN) warnIfInsecureHttpUrl(API_ORIGIN, 'VITE_API_URL'); // ============================================================================= // Types // ============================================================================= export interface Session { id: string; name: string; status: | 'created' | 'idle' | 'initializing' | 'connecting' | 'authenticating' | 'qr_ready' | 'ready' | 'disconnected' | 'failed'; phone?: string; pushName?: string; lastActive?: string; createdAt: string; updatedAt: string; /** Human-readable reason for the most recent terminal engine failure (set only when status is 'failed'). */ lastError?: string | null; } export interface SessionStats { total: number; active: number; ready: number; disconnected: number; byStatus: Record; memoryUsage: { heapUsed: number; heapTotal: number; rss: number }; } export type WebhookFilterOperator = 'is' | 'isNot' | 'contains' | 'equals'; export interface WebhookFilterCondition { field: string; operator: WebhookFilterOperator; value: string | string[] | boolean; caseSensitive?: boolean; } export interface WebhookFilters { conditions: WebhookFilterCondition[]; } export interface Webhook { id: string; sessionId: string; url: string; events: string[]; filters?: WebhookFilters | null; active: boolean; secret?: string; createdAt: string; updatedAt: string; } export interface MessageTemplate { id: string; sessionId: string; name: string; body: string; header?: string | null; footer?: string | null; createdAt: string; updatedAt: string; } export interface TemplatePayload { name: string; body: string; header?: string | null; footer?: string | null; } export interface ApiKey { id: string; name: string; keyPrefix: string; role: 'admin' | 'operator' | 'viewer'; allowedIps?: string[]; allowedSessions?: string[]; isActive: boolean; expiresAt?: string; lastUsedAt?: string; usageCount: number; createdAt: string; apiKey?: string; // Only returned on creation } export interface AuditLog { id: string; action: string; severity: 'info' | 'warn' | 'error'; apiKeyId?: string; apiKeyName?: string; sessionId?: string; sessionName?: string; ipAddress?: string; method?: string; path?: string; statusCode?: number; errorMessage?: string; createdAt: string; } export interface MessageResponse { messageId: string; timestamp: number; } // Chat summary returned by GET /sessions/:id/chats (mirrors the backend ChatSummary). export interface Chat { id: string; name: string; isGroup: boolean; unreadCount: number; timestamp: number; lastMessage?: string; } // Engine-neutral message types (mirrors the backend's IWhatsAppEngine MessageType). The backend // normalizes raw engine tokens at the adapter boundary (#265/#270), so persisted rows, the // message.received/sent payloads, and the websocket all use these values. export const MESSAGE_TYPES = [ 'text', 'image', 'video', 'audio', 'voice', 'document', 'sticker', 'location', 'contact', 'poll', 'call', 'revoked', 'masked', 'unknown', ] as const; export type MessageType = (typeof MESSAGE_TYPES)[number]; /** Coerce an arbitrary string (e.g. a raw websocket payload field) to a known MessageType. */ export function asMessageType(value: string | undefined): MessageType { return (MESSAGE_TYPES as readonly string[]).includes(value ?? '') ? (value as MessageType) : 'unknown'; } export interface ChatMessage { id: string; waMessageId?: string; chatId: string; from: string; to: string; body: string; type: MessageType; direction: 'incoming' | 'outgoing'; status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; timestamp?: number; createdAt: string; metadata?: { media?: { mimetype: string; filename?: string; data?: string; omitted?: boolean; sizeBytes?: number }; quotedMessage?: { id: string; body: string }; reactions?: Record; call?: { video: boolean; missed: boolean }; }; } // Live WhatsApp message from the engine history endpoint (not a persisted DB row): it carries `fromMe` // instead of `direction`/`status`. Used to backfill a chat thread the gateway never captured live. export interface EngineHistoryMessage { id: string; chatId: string; from: string; to: string; body: string; type: string; timestamp: number; fromMe?: boolean; media?: { mimetype: string; filename?: string; data?: string }; } export interface SendMediaPayload { base64?: string; url?: string; mimetype?: string; filename?: string; caption?: string; } // Payloads below mirror the backend DTOs in src/modules/message/dto (raw bodies, no envelope). export interface SendLocationPayload { chatId: string; latitude: number; longitude: number; description?: string; address?: string; } export interface SendContactPayload { chatId: string; contactName: string; contactNumber: string; } export interface SendPollPayload { chatId: string; name: string; options: string[]; allowMultipleAnswers?: boolean; } export interface ForwardMessagePayload { fromChatId: string; toChatId: string; messageId: string; } // Media block of a single bulk message (BulkMediaDto — no caption; caption sits next to it). export interface BulkMediaPayload { url?: string; base64?: string; mimetype?: string; filename?: string; ptt?: boolean; } export interface BulkMessageItem { chatId: string; type: 'text' | 'image' | 'video' | 'audio' | 'document'; content: { text?: string; image?: BulkMediaPayload; video?: BulkMediaPayload; audio?: BulkMediaPayload; document?: BulkMediaPayload; caption?: string; }; variables?: Record; } export interface SendBulkPayload { batchId?: string; messages: BulkMessageItem[]; options?: { delayBetweenMessages?: number; randomizeDelay?: boolean; stopOnError?: boolean; }; } /** 202 response of POST send-bulk — the batch is processing asynchronously; poll getBatchStatus. */ export interface BulkBatchResponse { batchId: string; status: string; totalMessages: number; estimatedCompletionTime?: string; statusUrl: string; } export type BatchStatus = 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'; export interface BatchProgress { total: number; sent: number; failed: number; pending: number; cancelled: number; } export interface BatchMessageResult { chatId: string; status: 'pending' | 'sent' | 'failed' | 'cancelled'; messageId?: string; error?: { code: string; message: string }; sentAt?: string; } /** GET batch/:batchId shape; the cancel endpoint returns the same minus results/timestamps. */ export interface BatchStatusResponse { batchId: string; status: BatchStatus; progress: BatchProgress; results?: BatchMessageResult[]; startedAt?: string; completedAt?: string; } export interface HealthStatus { status: 'ok' | 'error'; timestamp?: string; /** Running backend version (from package.json) — read live so the sidebar never shows a stale build. */ version?: string; details?: { database?: { status: string }; redis?: { status: string }; queue?: { status: string }; }; } export interface InfraStatus { // `builtIn` = OpenWA's own bundled container is actually running and backing this service (live), // not just the saved intent — falls back to the saved flag when Docker is unavailable. (#488) database: { connected: boolean; type: string; host: string; builtIn: boolean }; redis: { enabled: boolean; connected: boolean; host: string; port: number; builtIn: boolean }; queue: { enabled: boolean; webhooks: { pending: number; completed: number; failed: number }; }; storage: { type: 'local' | 's3'; path?: string; bucket?: string; builtIn: boolean; s3Available?: boolean }; engine: { type: string; headless: boolean; // whatsapp-web.js only: the actual WhatsApp Web build in use (distinct from the library version) // and how it was chosen. (#488) webVersion?: string | null; webVersionSource?: 'pinned' | 'auto' | 'native'; }; } // Saved infrastructure config (from data/.env.generated) used to hydrate the form. // Secrets are never returned — `*Set` flags indicate whether a value is stored. export interface SavedConfig { database: { type: 'sqlite' | 'postgres'; builtIn: boolean; host: string; port: string; username: string; database: string; schema: string; poolSize: number; sslEnabled: boolean; sslRejectUnauthorized: boolean; passwordSet: boolean; }; redis: { enabled: boolean; builtIn: boolean; host: string; port: string; passwordSet: boolean }; queue: { enabled: boolean }; storage: { type: 'local' | 's3'; builtIn: boolean; localPath: string; s3Bucket: string; s3Region: string; s3Endpoint: string; s3CredentialsSet: boolean; }; engine: { type: string; headless: boolean; sessionDataPath: string; browserArgs: string }; } export interface SaveConfigPayload { database?: { type: 'sqlite' | 'postgres'; builtIn?: boolean; host?: string; port?: string; username?: string; password?: string; database?: string; schema?: string; poolSize?: number; sslEnabled?: boolean; sslRejectUnauthorized?: boolean; }; redis?: { enabled?: boolean; builtIn?: boolean; host?: string; port?: string; password?: string; }; queue?: { enabled?: boolean; }; storage?: { type: 'local' | 's3'; builtIn?: boolean; localPath?: string; s3Bucket?: string; s3Region?: string; s3AccessKey?: string; s3SecretKey?: string; s3Endpoint?: string; }; engine?: { type?: string; headless?: boolean; sessionDataPath?: string; browserArgs?: string; }; } export interface Settings { general: { apiBaseUrl: string; autoReconnect: boolean; debugMode: boolean }; api: { rateLimit: number; rateLimitWindow: number; enableDocs: boolean }; notifications: { emailEnabled: boolean; notificationEmail: string; webhookAlerts: boolean }; } // Global message search (mirrors the backend GET /search contract from #664). // `timestamp` is epoch-seconds (the messages column is seconds, not ms); `dateFrom`/`dateTo` // are epoch-ms on the wire — see `dateFrom`/`dateTo` JSDoc below. export interface SearchParams { q: string; sessionId?: string; chatId?: string; direction?: string; type?: string; from?: string; /** Epoch-ms lower bound (inclusive) — the backend binds against messages.timestamp (/1000). */ dateFrom?: number; /** Epoch-ms upper bound (inclusive). */ dateTo?: number; limit?: number; offset?: number; } export interface SearchHit { messageId: string; waMessageId: string; sessionId: string; chatId: string; body: string; /** Provider-generated excerpt with `` highlight markers — render as text, never as HTML. */ snippet: string; /** Epoch-seconds (mirrors the persisted messages.timestamp column). */ timestamp: number; type: string; direction: string; from: string; score?: number; } export interface SearchResults { hits: SearchHit[]; total: number; tookMs: number; provider: string; } // ============================================================================= // API Client // ============================================================================= async function request(endpoint: string, options: RequestInit = {}): Promise { const url = `${API_BASE_URL}${endpoint}`; // Get API key from sessionStorage for authentication const apiKey = sessionStorage.getItem('openwa_api_key'); // For FormData (file uploads) let the browser set multipart/form-data + boundary itself. const isFormData = options.body instanceof FormData; const headers: HeadersInit = { ...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...(apiKey ? { 'X-API-Key': apiKey } : {}), ...options.headers, }; const response = await fetch(url, { ...options, headers }); if (response.status === 401) { // The stored API key is invalid/expired/revoked — clear it and return to login // so the user isn't stuck on a dashboard that 401s every request. sessionStorage.removeItem('openwa_api_key'); if (typeof window !== 'undefined') { window.location.assign('/'); // The page is navigating away — halt this request's promise chain so callers neither // throw the generic error below (flashing a toast) nor receive an undefined payload. return new Promise(() => {}); } } if (!response.ok) { // On a non-JSON body (e.g. a reverse-proxy 502/503 HTML page) fall through to `HTTP ` // rather than statusText: the status code is what the toast connection-lost de-dup matches on, // and statusText is empty over HTTP/2 anyway. const error = await response.json().catch(() => ({})); // Carry the HTTP status on the Error (message unchanged, so the toast de-dup still matches) so // callers can tell apart a permission 403 from a real server 5xx instead of guessing from text. const err = new Error(error.message || `HTTP ${response.status}`) as Error & { status?: number }; err.status = response.status; throw err; } if (response.status === 204) { return undefined as T; } return response.json(); } /** Like {@link request} but returns the raw response text — e.g. a plugin's HTML config-UI bundle. */ async function requestText(endpoint: string): Promise { const apiKey = sessionStorage.getItem('openwa_api_key'); const response = await fetch(`${API_BASE_URL}${endpoint}`, { headers: { ...(apiKey ? { 'X-API-Key': apiKey } : {}) }, }); if (response.status === 401) { sessionStorage.removeItem('openwa_api_key'); if (typeof window !== 'undefined') { window.location.assign('/'); return new Promise(() => {}); } } if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.message || `HTTP ${response.status}`); } return response.text(); } // ============================================================================= // Session API // ============================================================================= export const sessionApi = { list: () => request('/sessions'), get: (id: string) => request(`/sessions/${id}`), create: (name: string) => request('/sessions', { method: 'POST', body: JSON.stringify({ name }), }), delete: (id: string) => request(`/sessions/${id}`, { method: 'DELETE' }), start: (id: string) => request(`/sessions/${id}/start`, { method: 'POST' }), stop: (id: string) => request(`/sessions/${id}/stop`, { method: 'POST' }), forceKill: (id: string) => request(`/sessions/${id}/force-kill`, { method: 'POST' }), getQR: (id: string) => request<{ qrCode: string; status: string }>(`/sessions/${id}/qr`), requestPairingCode: (id: string, phoneNumber: string) => request<{ pairingCode: string; status: string }>(`/sessions/${id}/pairing-code`, { method: 'POST', body: JSON.stringify({ phoneNumber }), }), getStats: () => request('/sessions/stats/overview'), getGroups: (id: string) => request<{ id: string; name: string; linkedParentJID?: string | null }[]>(`/sessions/${id}/groups`), getChats: (id: string) => request(`/sessions/${id}/chats`), markChatRead: (id: string, chatId: string) => request<{ success: boolean }>(`/sessions/${id}/chats/read`, { method: 'POST', body: JSON.stringify({ chatId }), }), markChatUnread: (id: string, chatId: string) => request<{ success: boolean }>(`/sessions/${id}/chats/unread`, { method: 'POST', body: JSON.stringify({ chatId }), }), getChatMessages: (id: string, chatId: string, limit = 100) => request<{ messages: ChatMessage[]; total: number }>( `/sessions/${id}/messages?chatId=${encodeURIComponent(chatId)}&limit=${limit}`, ), // Live history straight from WhatsApp (bypasses the DB) — backfills a thread the gateway never // captured, e.g. a freshly paired session whose persisted store is still empty. // includeMedia downloads the media payload (base64) for history messages so stickers/images/ // video/voice render instead of collapsing to an empty timestamp-only bubble. getChatHistory: (id: string, chatId: string, limit = 100, includeMedia = false) => request( `/sessions/${id}/messages/${encodeURIComponent(chatId)}/history?limit=${limit}${ includeMedia ? '&includeMedia=true' : '' }`, ), }; // ============================================================================= // Webhook API // ============================================================================= export const webhookApi = { listBySession: (sessionId: string) => request(`/sessions/${sessionId}/webhooks`), listAll: () => request('/webhooks'), get: (sessionId: string, id: string) => request(`/sessions/${sessionId}/webhooks/${id}`), create: (sessionId: string, data: { url: string; events: string[]; filters?: WebhookFilters | null }) => request(`/sessions/${sessionId}/webhooks`, { method: 'POST', body: JSON.stringify(data), }), update: (sessionId: string, id: string, data: Partial) => request(`/sessions/${sessionId}/webhooks/${id}`, { method: 'PUT', body: JSON.stringify(data), }), delete: (sessionId: string, id: string) => request(`/sessions/${sessionId}/webhooks/${id}`, { method: 'DELETE' }), test: (sessionId: string, id: string) => request<{ success: boolean; statusCode?: number; error?: string }>(`/sessions/${sessionId}/webhooks/${id}/test`, { method: 'POST', }), }; // ============================================================================= // Template API // ============================================================================= export const templateApi = { list: (sessionId: string) => request(`/sessions/${sessionId}/templates`), get: (sessionId: string, id: string) => request(`/sessions/${sessionId}/templates/${id}`), create: (sessionId: string, data: TemplatePayload) => request(`/sessions/${sessionId}/templates`, { method: 'POST', body: JSON.stringify(data), }), update: (sessionId: string, id: string, data: Partial) => request(`/sessions/${sessionId}/templates/${id}`, { method: 'PUT', body: JSON.stringify(data), }), delete: (sessionId: string, id: string) => request(`/sessions/${sessionId}/templates/${id}`, { method: 'DELETE' }), }; // ============================================================================= // Contact API // ============================================================================= export interface CheckNumberResponse { number: string; exists: boolean; /** Engine-canonical WhatsApp id for the number (e.g. `…@c.us` or `…@lid`), or null if unregistered. */ whatsappId: string | null; } export interface ProfilePictureResponse { /** Signed CDN URL for the contact/group picture, or null when hidden / unavailable. */ url: string | null; } export const contactApi = { checkNumber: (sessionId: string, number: string) => request(`/sessions/${sessionId}/contacts/check/${encodeURIComponent(number)}`), // Returns the contact/group profile picture URL. Both engines return null when the user hid their // picture or has none. The URL is a signed WhatsApp CDN link that expires in a few hours, so the // dashboard caches it for an hour (see useProfilePicture) and re-fetches on expiry. profilePicture: (sessionId: string, contactId: string) => request(`/sessions/${sessionId}/contacts/${encodeURIComponent(contactId)}/profile-picture`), // Best-effort resolution of a contact id (e.g. an @lid privacy id) to its phone number (MSISDN // digits), or null when the engine can't map it. Cached a day by useResolvedPhone. resolvePhone: (sessionId: string, contactId: string) => request<{ contactId: string; phone: string | null }>( `/sessions/${sessionId}/contacts/${encodeURIComponent(contactId)}/phone`, ), // Batch-resolve profile picture URLs for a whole sidebar in ONE request — the per-chat burst of // parallel single fetches exhausts the per-IP throttle (429s). Engine lookups run 3 at a time // server-side; ids beyond the backend's 50-id cap are dropped client-side too. profilePictures: (sessionId: string, contactIds: string[]) => request<{ pictures: Record }>( `/sessions/${sessionId}/contacts/profile-pictures?ids=${contactIds .slice(0, 50) .map(encodeURIComponent) .join(',')}`, ), }; // ============================================================================= // API Key API // ============================================================================= export const apiKeyApi = { list: () => request('/auth/api-keys'), get: (id: string) => request(`/auth/api-keys/${id}`), create: (data: { name: string; role: string; allowedIps?: string[]; allowedSessions?: string[]; expiresAt?: string; }) => request('/auth/api-keys', { method: 'POST', body: JSON.stringify(data), }), update: (id: string, data: Partial) => request(`/auth/api-keys/${id}`, { method: 'PUT', body: JSON.stringify(data), }), delete: (id: string) => request(`/auth/api-keys/${id}`, { method: 'DELETE' }), revoke: (id: string) => request(`/auth/api-keys/${id}/revoke`, { method: 'POST' }), }; // ============================================================================= // Audit/Logs API // ============================================================================= export const auditApi = { list: (params?: { action?: string; severity?: string; limit?: number; offset?: number }) => { const query = new URLSearchParams(); if (params?.action) query.set('action', params.action); if (params?.severity) query.set('severity', params.severity); if (params?.limit) query.set('limit', String(params.limit)); if (params?.offset) query.set('offset', String(params.offset)); const queryStr = query.toString(); return request<{ data: AuditLog[]; total: number }>(`/audit${queryStr ? `?${queryStr}` : ''}`); }, }; // ============================================================================= // Message API // ============================================================================= export const messageApi = { sendText: (sessionId: string, chatId: string, text: string) => request(`/sessions/${sessionId}/messages/send-text`, { method: 'POST', body: JSON.stringify({ chatId, text }), }), sendImage: (sessionId: string, chatId: string, url: string, caption?: string) => request(`/sessions/${sessionId}/messages/send-image`, { method: 'POST', body: JSON.stringify({ chatId, url, caption }), }), sendVideo: (sessionId: string, chatId: string, url: string, caption?: string) => request(`/sessions/${sessionId}/messages/send-video`, { method: 'POST', body: JSON.stringify({ chatId, url, caption }), }), sendAudio: (sessionId: string, chatId: string, url: string) => request(`/sessions/${sessionId}/messages/send-audio`, { method: 'POST', body: JSON.stringify({ chatId, url }), }), sendDocument: (sessionId: string, chatId: string, url: string, filename?: string) => request(`/sessions/${sessionId}/messages/send-document`, { method: 'POST', body: JSON.stringify({ chatId, url, filename }), }), sendMedia: ( sessionId: string, chatId: string, mediaType: 'image' | 'video' | 'audio' | 'document', payload: SendMediaPayload, ) => request(`/sessions/${sessionId}/messages/send-${mediaType}`, { method: 'POST', body: JSON.stringify({ chatId, ...payload }), }), sendLocation: (sessionId: string, data: SendLocationPayload) => request(`/sessions/${sessionId}/messages/send-location`, { method: 'POST', body: JSON.stringify(data), }), sendContact: (sessionId: string, data: SendContactPayload) => request(`/sessions/${sessionId}/messages/send-contact`, { method: 'POST', body: JSON.stringify(data), }), // Stickers take the same media body as the other send-* endpoints (base64 XOR url + mimetype). sendSticker: (sessionId: string, chatId: string, payload: SendMediaPayload) => request(`/sessions/${sessionId}/messages/send-sticker`, { method: 'POST', body: JSON.stringify({ chatId, ...payload }), }), sendPoll: (sessionId: string, data: SendPollPayload) => request(`/sessions/${sessionId}/messages/send-poll`, { method: 'POST', body: JSON.stringify(data), }), forward: (sessionId: string, data: ForwardMessagePayload) => request(`/sessions/${sessionId}/messages/forward`, { method: 'POST', body: JSON.stringify(data), }), // Async batch: returns 202 immediately; poll getBatchStatus until a terminal status. sendBulk: (sessionId: string, data: SendBulkPayload) => request(`/sessions/${sessionId}/messages/send-bulk`, { method: 'POST', body: JSON.stringify(data), }), getBatchStatus: (sessionId: string, batchId: string) => request(`/sessions/${sessionId}/messages/batch/${encodeURIComponent(batchId)}`), cancelBatch: (sessionId: string, batchId: string) => request(`/sessions/${sessionId}/messages/batch/${encodeURIComponent(batchId)}/cancel`, { method: 'POST', }), reply: (sessionId: string, data: { chatId: string; quotedMessageId: string; text: string }) => request(`/sessions/${sessionId}/messages/reply`, { method: 'POST', body: JSON.stringify(data), }), react: (sessionId: string, data: { chatId: string; messageId: string; emoji: string }) => request(`/sessions/${sessionId}/messages/react`, { method: 'POST', body: JSON.stringify(data), }), sendTemplate: ( sessionId: string, data: { chatId: string; templateId?: string; templateName?: string; variables?: Record }, ) => request(`/sessions/${sessionId}/messages/send-template`, { method: 'POST', body: JSON.stringify(data), }), delete: (sessionId: string, data: { chatId: string; messageId: string; forEveryone?: boolean }) => request(`/sessions/${sessionId}/messages/delete`, { method: 'POST', body: JSON.stringify(data), }), }; // ============================================================================= // Search API // ============================================================================= export const searchApi = { search: (params: SearchParams) => { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== '') query.set(key, String(value)); }); return request(`/search?${query.toString()}`); }, }; // ============================================================================= // Health & Infrastructure API // ============================================================================= export const healthApi = { check: () => request('/health'), ready: () => request('/health/ready'), }; export const infraApi = { getStatus: () => request('/infra/status'), getConfig: () => request('/infra/config'), updateConfig: (config: Partial) => request('/infra/config', { method: 'PUT', body: JSON.stringify(config), }), saveConfig: (config: SaveConfigPayload) => request<{ message: string; saved: boolean; envPath: string; profiles: string[] }>('/infra/config', { method: 'PUT', body: JSON.stringify(config), }), restart: (profiles?: string[], profilesToRemove?: string[]) => request<{ message: string; restarting: boolean; profiles: string[]; profilesToRemove: string[]; estimatedTime: number; }>('/infra/restart', { method: 'POST', body: JSON.stringify({ profiles: profiles || [], profilesToRemove: profilesToRemove || [] }), }), healthCheck: () => request<{ status: string; timestamp: string }>('/infra/health'), // Data migration: export all Data-DB tables (call while still on the OLD database, before switching), // then import after the switch + restart. Used by the DB-switch migration guard so data isn't lost. exportData: () => request<{ exportedAt: string; dataDbType: string; tables: Record; counts: Record; }>('/infra/export-data'), importData: (tables: Record) => request<{ imported: boolean; counts?: Record; message?: string; warnings?: string[] }>( '/infra/import-data', { method: 'POST', body: JSON.stringify({ tables }), }, ), }; // ============================================================================= // Plugin Types // ============================================================================= /** Field definition within a plugin's config schema (mirrors the backend PluginConfigField). */ export interface PluginConfigField { // 'textarea' is a multi-line string; a field with `enum` renders as a