import { APIKey, AuthStatus, DashboardStats, Document, DocumentBlock, DocShare, DocLockInfo, KanbanColumn, ListItem, ListShare, Organization, PriorityLevel, SharedList, SharedFolder, FolderShare, Space, SpaceMember, SpaceShare, SystemSettings, Team, TaskList, TaskListShare, Thought, SubTask, TagDetails, TagSummary, User, UserInvite, WSMessage, } from './types'; const TOKEN_STORAGE_KEY = 'mindsift_jwt_token'; const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue'; export function getToken(): string | null { return localStorage.getItem(TOKEN_STORAGE_KEY); } export function setToken(token: string): void { localStorage.setItem(TOKEN_STORAGE_KEY, token); } export function clearToken(): void { localStorage.removeItem(TOKEN_STORAGE_KEY); } export function triggerHaptic(pattern: number | number[] = 15): void { try { if (typeof navigator !== 'undefined' && 'vibrate' in navigator) { navigator.vibrate(pattern); } } catch { // Ignore on desktop browsers } } function ensureArray(val: T[] | null | undefined): T[] { return Array.isArray(val) ? val : []; } async function request(path: string, options: RequestInit = {}): Promise { const token = getToken(); const headers: Record = { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', ...(options.headers as Record), }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 12000); let res: Response; try { res = await fetch(path, { ...options, headers, cache: 'no-store', signal: controller.signal, }); } finally { window.clearTimeout(timeoutId); } if ( res.status === 401 && !path.includes('/auth/login') && !path.includes('/auth/register') && !path.includes('/auth/setup') && !path.includes('/auth/status') ) { clearToken(); window.dispatchEvent(new CustomEvent('mindsift:unauthorized')); } if (!res.ok) { let errMsg = `HTTP ${res.status}`; try { const errData = await res.json(); if (errData.error) errMsg = errData.error; } catch { // ignore } throw new Error(errMsg); } return res.json(); } export const api = { // --- AUTH, FIRST-BOOT SETUP & SETTINGS --- async getAuthStatus(): Promise { return request('/api/v1/auth/status'); }, async completeSetup(payload: { email: string; password: string; displayName: string; instanceName?: string; sharingMode?: 'open' | 'teams_only' | 'strict_private'; allowRegistration?: boolean; createSampleData?: boolean; }): Promise<{ token: string; user: User }> { const res = await request<{ token: string; user: User }>('/api/v1/auth/setup', { method: 'POST', body: JSON.stringify(payload), }); setToken(res.token); return res; }, async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> { const res = await request<{ token: string; user: User }>('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ email: loginOrEmail, username: loginOrEmail, login: loginOrEmail, password }), }); setToken(res.token); return res; }, async register( email: string, password: string, displayName: string, inviteCode?: string ): Promise<{ token: string; user: User }> { const res = await request<{ token: string; user: User }>('/api/v1/auth/register', { method: 'POST', body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }), }); setToken(res.token); return res; }, async getMe(): Promise<{ user: User; stats: DashboardStats }> { return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me'); }, async updateSettings(patch: { displayName?: string; email?: string; password?: string; avatarUrl?: string; alarmChannels?: string[]; alarmEmail?: string; discordWebhookUrl?: string; customWebhookUrl?: string; notifyBeforeMinutes?: number; }): Promise { return request('/api/v1/auth/settings', { method: 'PATCH', body: JSON.stringify(patch), }); }, async testAlarmChannel( channel: 'email' | 'discord' | 'webhook', target?: string ): Promise<{ status: string }> { return request<{ status: string }>('/api/v1/auth/alarms/test', { method: 'POST', body: JSON.stringify({ channel, target: target || '' }), }); }, async getStats(): Promise { return request('/api/v1/stats'); }, async getUsersDirectory(): Promise { const res = await request('/api/v1/users/directory'); return ensureArray(res); }, // --- INSTANT CAPTURE & OFFLINE SYNC --- async captureThought( content: string, priority: PriorityLevel = 'normal', source: 'manual' | 'voice' = 'manual', dueDate = '', options?: { target?: 'inbox' | 'task' | 'list_item' | 'document'; targetListId?: string; taskListId?: string; } ): Promise { try { const thought = await request('/api/v1/inbox', { method: 'POST', body: JSON.stringify({ content, priority, source, dueDate, target: options?.target || 'inbox', targetListId: options?.targetListId || '', taskListId: options?.taskListId || '', }), }); return thought; } catch { // Queue locally if offline const queue = getOfflineQueue(); const offlineThought: Thought = { id: `th_offline_${Date.now()}`, userId: 'offline', content, tags: (content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase()), priority, source, status: 'inbox', isCompleted: false, dueDate, taskListId: options?.taskListId || '', createdAt: new Date().toISOString(), }; queue.push(offlineThought); localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue)); return offlineThought; } }, async flushOfflineQueue(): Promise { const queue = getOfflineQueue(); if (queue.length === 0) return 0; let synced = 0; const remaining: Thought[] = []; for (const item of queue) { try { await request('/api/v1/inbox', { method: 'POST', body: JSON.stringify({ content: item.content, priority: item.priority, source: item.source, dueDate: item.dueDate || '', taskListId: item.taskListId || '', }), }); synced++; } catch { remaining.push(item); } } localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(remaining)); return synced; }, // --- THOUGHTS, TRIAGE & TASKS --- async getTriageDeck(): Promise { const res = await request('/api/v1/triage/deck'); return ensureArray(res); }, async getThoughts(status = 'all'): Promise { const res = await request(`/api/v1/thoughts?status=${encodeURIComponent(status)}`); return ensureArray(res); }, async triageAction( thoughtId: string, action: 'keep' | 'discard' | 'restore', options?: { convertTo?: 'task' | 'document' | 'list_item'; targetListId?: string; taskListId?: string; dueDate?: string; } ): Promise { return request(`/api/v1/triage/${thoughtId}`, { method: 'PATCH', body: JSON.stringify({ action, convertTo: options?.convertTo || '', targetListId: options?.targetListId || '', taskListId: options?.taskListId || '', dueDate: options?.dueDate || '', }), }); }, async getTasks(taskListId?: string): Promise { const query = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : ''; const res = await request(`/api/v1/tasks${query}`); return ensureArray(res); }, async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> { const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder'; return request<{ success: boolean }>(url, { method: 'POST', body: JSON.stringify({ items }), }); }, async updateThought( thoughtId: string, patch: { content?: string; isCompleted?: boolean; dueDate?: string; priority?: string; taskListId?: string; kanbanStatus?: string; notes?: string; subtasks?: SubTask[]; tags?: string[]; } ): Promise { return request(`/api/v1/thoughts/${thoughtId}`, { method: 'PATCH', body: JSON.stringify(patch), }); }, async deleteThought(thoughtId: string): Promise { await request(`/api/v1/thoughts/${thoughtId}`, { method: 'DELETE' }); }, async purgeDiscarded(): Promise<{ purged: number }> { return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' }); }, // --- TASK LISTS --- async getTaskLists(): Promise { const res = await request('/api/v1/task-lists'); return ensureArray(res).map((tl) => ({ ...tl, members: ensureArray(tl.members), shares: ensureArray(tl.shares), columns: ensureArray(tl.columns), })); }, async createTaskList(payload: { title: string; icon?: string; color?: string; teamId?: string; spaceId?: string; columns?: KanbanColumn[]; }): Promise { return request('/api/v1/task-lists', { method: 'POST', body: JSON.stringify(payload), }); }, async updateTaskList( id: string, patch: { title?: string; icon?: string; color?: string; teamId?: string; spaceId?: string; columns?: KanbanColumn[]; } ): Promise { return request(`/api/v1/task-lists/${id}`, { method: 'PUT', body: JSON.stringify(patch), }); }, async deleteTaskList(id: string): Promise { await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' }); }, async getTaskListShares(id: string): Promise { const res = await request(`/api/v1/task-lists/${id}/shares`); return ensureArray(res); }, async addTaskListShare( id: string, payload: { targetType: 'team' | 'user'; targetId: string } ): Promise { return request(`/api/v1/task-lists/${id}/shares`, { method: 'POST', body: JSON.stringify(payload), }); }, async removeTaskListShare(id: string, shareId: string): Promise { await request(`/api/v1/task-lists/${id}/shares/${shareId}`, { method: 'DELETE' }); }, // --- WORKSPACE FOLDERS --- async getFolders(): Promise { const res = await request('/api/v1/folders'); return ensureArray(res); }, async createFolder(path: string): Promise<{ path: string }> { return request<{ path: string }>('/api/v1/folders', { method: 'POST', body: JSON.stringify({ path }), }); }, async deleteFolder(path: string): Promise<{ deleted: boolean; deletedDocIds: string[] }> { return request<{ deleted: boolean; deletedDocIds: string[] }>( `/api/v1/folders?path=${encodeURIComponent(path)}`, { method: 'DELETE' } ); }, async moveFolder(sourcePath: string, targetParentPath: string): Promise<{ newPath: string; movedDocIds: string[] }> { return request<{ newPath: string; movedDocIds: string[] }>('/api/v1/folders/move', { method: 'POST', body: JSON.stringify({ sourcePath, targetParentPath }), }); }, // --- WORKSPACE SHARED FOLDERS --- async getSharedFolders(): Promise { const res = await request('/api/v1/workspace/folders/shared'); return ensureArray(res); }, async createSharedFolder(payload: { name: string; mountPath?: string }): Promise { return request('/api/v1/workspace/folders/shared', { method: 'POST', body: JSON.stringify(payload), }); }, async getSharedFolder(id: string): Promise { return request(`/api/v1/workspace/folders/shared/${id}`); }, async deleteSharedFolder(id: string): Promise { await request(`/api/v1/workspace/folders/shared/${id}`, { method: 'DELETE' }); }, async updateFolderMount(id: string, mountPath: string): Promise { await request(`/api/v1/workspace/folders/shared/${id}/mount`, { method: 'PUT', body: JSON.stringify({ mountPath }), }); }, async getFolderShares(folderId: string): Promise { const res = await request(`/api/v1/workspace/folders/shared/${folderId}/shares`); return ensureArray(res); }, async createFolderShare( folderId: string, payload: { targetType?: 'team' | 'user'; targetId?: string; userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' } ): Promise { return request(`/api/v1/workspace/folders/shared/${folderId}/shares`, { method: 'POST', body: JSON.stringify(payload), }); }, async deleteFolderShare(folderId: string, shareId: string): Promise { await request(`/api/v1/workspace/folders/shared/${folderId}/shares/${shareId}`, { method: 'DELETE' }); }, // --- WORKSPACE TAGS --- async getWorkspaceTags(): Promise { const res = await request('/api/v1/workspace/tags'); return ensureArray(res); }, async getTagDetails(tag: string): Promise { const clean = encodeURIComponent(tag.replace(/^#/, '')); const res = await request(`/api/v1/workspace/tags/${clean}`); return { ...res, documents: ensureArray(res.documents).map((doc) => ({ ...doc, matchedBlocks: ensureArray(doc.matchedBlocks).map((mb) => ({ rootBlock: mb.rootBlock, children: ensureArray(mb.children), })), })), }; }, async updateTagPageNote( tag: string, content: string ): Promise<{ tag: string; content: string; updatedAt: string }> { const clean = encodeURIComponent(tag.replace(/^#/, '')); return request<{ tag: string; content: string; updatedAt: string }>( `/api/v1/workspace/tags/${clean}/note`, { method: 'PUT', body: JSON.stringify({ content }), } ); }, // --- WORKSPACE DOCUMENTS --- async getDocuments(): Promise { const res = await request('/api/v1/documents'); return ensureArray(res).map((d) => ({ ...d, blocks: ensureArray(d.blocks), tags: ensureArray(d.tags), })); }, async getDocument(id: string): Promise { const d = await request(`/api/v1/documents/${id}`); return { ...d, blocks: ensureArray(d.blocks), tags: ensureArray(d.tags), }; }, async createDocument(payload: { title: string; icon?: string; folderPath?: string; blocks?: DocumentBlock[]; tags?: string[]; isPinned?: boolean; }): Promise { const d = await request('/api/v1/documents', { method: 'POST', body: JSON.stringify(payload), }); return { ...d, blocks: ensureArray(d.blocks), tags: ensureArray(d.tags), }; }, async updateDocument( id: string, patch: { title?: string; icon?: string; folderPath?: string; blocks?: DocumentBlock[]; tags?: string[]; isPinned?: boolean; } ): Promise { const d = await request(`/api/v1/documents/${id}`, { method: 'PUT', body: JSON.stringify(patch), }); return { ...d, blocks: ensureArray(d.blocks), tags: ensureArray(d.tags), }; }, async patchDocumentBlocks( id: string, payload: { upserted?: DocumentBlock[]; deletedIds?: string[]; blockOrder?: string[]; } ): Promise { const d = await request(`/api/v1/documents/${id}/blocks`, { method: 'PATCH', body: JSON.stringify(payload), }); return { ...d, blocks: ensureArray(d.blocks), tags: ensureArray(d.tags), }; }, async getDocumentCRDT(id: string): Promise<{ docId: string; state?: string; updates?: string[] }> { return request<{ docId: string; state?: string; updates?: string[] }>(`/api/v1/documents/${id}/crdt`); }, async saveDocumentCRDT(id: string, payload: string | { state?: string; update?: string }): Promise<{ success: boolean }> { const body = typeof payload === 'string' ? { state: payload } : payload; return request<{ success: boolean }>(`/api/v1/documents/${id}/crdt`, { method: 'PUT', body: JSON.stringify(body), }); }, async sendDocumentAwareness(id: string, payload: any): Promise<{ success: boolean }> { return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, { method: 'POST', body: JSON.stringify(payload), }); }, async clearDocumentAwareness(id: string): Promise<{ success: boolean }> { return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, { method: 'DELETE', }); }, async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> { return request<{ success: boolean }>('/api/v1/events/rooms', { method: 'POST', body: JSON.stringify({ sseId, room, action }), }); }, async deleteDocument(id: string): Promise { await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); }, async moveDocument(id: string, folderPath: string): Promise { const d = await request(`/api/v1/documents/${id}/move`, { method: 'PUT', body: JSON.stringify({ folderPath }), }); return { ...d, blocks: ensureArray(d.blocks), tags: ensureArray(d.tags), }; }, // --- DOCUMENT SHARING --- async getDocShares(docId: string): Promise { const res = await request(`/api/v1/documents/${docId}/shares`); return ensureArray(res); }, async createDocShare( docId: string, payload: { userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' } ): Promise { return request(`/api/v1/documents/${docId}/shares`, { method: 'POST', body: JSON.stringify(payload), }); }, async deleteDocShare(docId: string, userId: string): Promise { await request(`/api/v1/documents/${docId}/shares/${userId}`, { method: 'DELETE' }); }, async getCollaborators(): Promise { const res = await request('/api/v1/collaborators'); return ensureArray(res); }, // --- DOCUMENT LOCKING --- async getDocLock(docId: string): Promise<{ locked: boolean; lock?: DocLockInfo }> { return request<{ locked: boolean; lock?: DocLockInfo }>(`/api/v1/documents/${docId}/lock`); }, async lockDocument( docId: string ): Promise<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }> { try { return await request<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }>( `/api/v1/documents/${docId}/lock`, { method: 'POST' } ); } catch (e: any) { return { locked: true, acquired: false, message: e.message }; } }, async heartbeatDocLock(docId: string): Promise<{ extended: boolean }> { try { return await request<{ extended: boolean }>(`/api/v1/documents/${docId}/lock/heartbeat`, { method: 'POST', }); } catch { return { extended: false }; } }, async unlockDocument(docId: string): Promise<{ unlocked: boolean }> { try { return await request<{ unlocked: boolean }>(`/api/v1/documents/${docId}/lock`, { method: 'DELETE', }); } catch { return { unlocked: false }; } }, // --- SPACES & REALTIME LISTS --- async getSpaces(): Promise { const res = await request('/api/v1/spaces'); return ensureArray(res).map((s) => ({ ...s, members: ensureArray(s.members), shares: ensureArray(s.shares), })); }, async getSpaceShares(spaceId: string): Promise { const res = await request(`/api/v1/spaces/${spaceId}/shares`); return ensureArray(res); }, async createSpace(name: string, icon: string, description: string): Promise { return request('/api/v1/spaces', { method: 'POST', body: JSON.stringify({ name, icon, description }), }); }, async joinSpace(inviteCode: string): Promise { return request('/api/v1/spaces/join', { method: 'POST', body: JSON.stringify({ inviteCode }), }); }, async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise { return request(`/api/v1/spaces/${spaceId}/members`, { method: 'POST', body: JSON.stringify(target), }); }, async removeSpaceMember(spaceId: string, userId: string): Promise { await request(`/api/v1/spaces/${spaceId}/members/${userId}`, { method: 'DELETE', }); }, async getSpaceLists(spaceId: string): Promise { const res = await request(`/api/v1/spaces/${spaceId}/lists`); return ensureArray(res).map((l) => ({ ...l, items: ensureArray(l.items), })); }, async getAllLists(): Promise { const res = await request('/api/v1/lists'); return ensureArray(res).map((l) => ({ ...l, items: ensureArray(l.items), shares: ensureArray(l.shares), })); }, async getListById(listId: string): Promise { const l = await request(`/api/v1/lists/${listId}`); return { ...l, items: ensureArray(l.items), shares: ensureArray(l.shares), }; }, async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise { let title = titleOrSpaceId; let icon = titleOrIcon || '🛒'; let type = (iconOrType as any) || 'shopping'; if (listType !== undefined) { title = titleOrIcon || ''; icon = iconOrType || '🛒'; type = listType; } const l = await request('/api/v1/lists', { method: 'POST', body: JSON.stringify({ title, icon, listType: type }), }); return { ...l, items: ensureArray(l.items), shares: ensureArray(l.shares), }; }, async deleteList(listId: string): Promise { await request(`/api/v1/lists/${listId}`, { method: 'DELETE' }); }, async getListShares(listId: string): Promise { const res = await request(`/api/v1/lists/${listId}/shares`); return ensureArray(res); }, async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise { return request(`/api/v1/lists/${listId}/shares`, { method: 'POST', body: JSON.stringify(target), }); }, async removeListShare(listId: string, shareId: string): Promise { await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' }); }, async createListItem(listId: string, content: string, category = '', quantity = ''): Promise { return request(`/api/v1/lists/${listId}/items`, { method: 'POST', body: JSON.stringify({ content, category, quantity }), }); }, async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise { return request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'PATCH', body: JSON.stringify({ isCompleted }), }); }, async deleteListItem(listId: string, itemId: string): Promise { await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' }); }, async clearCompletedItems(listId: string): Promise<{ removed: number }> { return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, { method: 'POST', }); }, async reorderListItems( listId: string, items: { id: string; sortOrder: number; category?: string; content?: string }[] ): Promise<{ success: boolean }> { return request<{ success: boolean }>(`/api/v1/lists/${listId}/items/reorder`, { method: 'POST', body: JSON.stringify({ items }), }); }, // --- ORGANIZATIONS & TEAMS --- async getOrganizations(): Promise { const res = await request('/api/v1/organizations'); return ensureArray(res).map((o) => ({ ...o, teams: ensureArray(o.teams).map((t) => ({ ...t, members: ensureArray(t.members), })), })); }, async createOrganization(name: string): Promise { return request('/api/v1/organizations', { method: 'POST', body: JSON.stringify({ name }), }); }, async deleteOrganization(id: string): Promise { await request(`/api/v1/organizations/${id}`, { method: 'DELETE' }); }, async getOrgTeams(orgId: string): Promise { const res = await request(`/api/v1/organizations/${orgId}/teams`); return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) })); }, async createOrgTeam(orgId: string, name: string, description: string): Promise { return request(`/api/v1/organizations/${orgId}/teams`, { method: 'POST', body: JSON.stringify({ name, description }), }); }, async createTeam(name: string, description = ''): Promise { return request('/api/v1/teams', { method: 'POST', body: JSON.stringify({ name, description }), }); }, async updateTeam(teamId: string, name: string, description = ''): Promise { return request(`/api/v1/teams/${teamId}`, { method: 'PUT', body: JSON.stringify({ name, description }), }); }, async deleteTeam(teamId: string): Promise { await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' }); }, async getUserTeams(): Promise { const res = await request('/api/v1/teams'); return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) })); }, async addTeamMember( teamId: string, payload: { userId?: string; email?: string; role: string } ): Promise { await request(`/api/v1/teams/${teamId}/members`, { method: 'POST', body: JSON.stringify(payload), }); }, async updateTeamMemberRole( teamId: string, userId: string, role: 'admin' | 'member' ): Promise { await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }), }); }, async removeTeamMember(teamId: string, userId: string): Promise { await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' }); }, // --- ADMIN & STAFF PANEL --- async adminGetUsers(): Promise { const res = await request('/api/v1/admin/users'); return ensureArray(res); }, async adminCreateUser(payload: { email: string; password: string; displayName: string; role: string; }): Promise { return request('/api/v1/admin/users', { method: 'POST', body: JSON.stringify(payload), }); }, async adminUpdateUser( id: string, patch: { displayName?: string; email?: string; role?: string; isActive?: boolean; discordWebhookUrl?: string; } ): Promise { return request(`/api/v1/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch), }); }, async adminResetPassword(id: string, password: string): Promise { await request(`/api/v1/admin/users/${id}/password`, { method: 'PUT', body: JSON.stringify({ password }), }); }, async adminDeleteUser(id: string): Promise { await request(`/api/v1/admin/users/${id}`, { method: 'DELETE' }); }, async adminGetInvites(): Promise { const res = await request('/api/v1/admin/invites'); return ensureArray(res); }, async adminCreateInvite(payload: { email?: string; role: string; teamId?: string; }): Promise { return request('/api/v1/admin/invites', { method: 'POST', body: JSON.stringify(payload), }); }, async adminDeleteInvite(id: string): Promise { await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' }); }, async adminGetSettings(): Promise { return request('/api/v1/admin/settings'); }, async adminUpdateSettings(settings: SystemSettings): Promise { return request('/api/v1/admin/settings', { method: 'PUT', body: JSON.stringify(settings), }); }, async adminTestSMTP(toEmail: string): Promise<{ status: string }> { return request<{ status: string }>('/api/v1/admin/smtp/test', { method: 'POST', body: JSON.stringify({ toEmail }), }); }, // --- API KEYS, WEBHOOKS & EXPORT --- async getAPIKeys(): Promise { const res = await request('/api/v1/keys'); return ensureArray(res); }, async createAPIKey(name: string): Promise { return request('/api/v1/keys', { method: 'POST', body: JSON.stringify({ name }), }); }, async deleteAPIKey(id: string): Promise { await request(`/api/v1/keys/${id}`, { method: 'DELETE' }); }, async testWebhookIngest( token: string, title: string, priority: PriorityLevel, tags: string[] ): Promise<{ status: string; thought: Thought }> { return request<{ status: string; thought: Thought }>( `/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`, { method: 'POST', body: JSON.stringify({ title, priority, tags }), } ); }, async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> { return request<{ thoughts: Thought[]; documents: Document[] }>( `/api/v1/search?q=${encodeURIComponent(q)}` ); }, async exportAll(): Promise { return request('/api/v1/export'); }, }; export function getOfflineQueue(): Thought[] { try { const raw = localStorage.getItem(OFFLINE_QUEUE_KEY); if (!raw) return []; return JSON.parse(raw); } catch { return []; } } export type RealtimeTransport = 'ws' | 'sse' | 'none'; let activeTransport: RealtimeTransport = 'none'; let activeGlobalWS: WebSocket | null = null; let activeGlobalSSE: EventSource | null = null; let activeSSEId: string | null = null; let wsReconnectTimer: any = null; let wsConnectionWatchdog: any = null; let wsListeners: ((msg: WSMessage) => void)[] = []; let realtimeStatusListeners: ((connected: boolean, transport: RealtimeTransport) => void)[] = []; let isRealtimeConnectedState = false; let manualDisconnect = false; let wsReconnectDelay = 2000; export function buildWebSocketURL(listId?: string): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = getToken() || ''; if (listId) { return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`; } return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`; } export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string { const token = getToken() || ''; const params = new URLSearchParams(); if (token) params.set('token', token); if (listId) params.set('listId', listId); if (docId) params.set('docId', docId); if (rooms && rooms.length > 0) params.set('rooms', rooms.join(',')); return `/api/v1/events?${params.toString()}`; } export function isWebSocketConnected(): boolean { return activeTransport === 'ws' && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN; } export function isRealtimeConnected(): boolean { return isRealtimeConnectedState; } export function getRealtimeTransport(): RealtimeTransport { return activeTransport; } export function getSSEId(): string | null { return activeSSEId; } export function onWebSocketStatus(callback: (connected: boolean) => void): () => void { const wrapper = (connected: boolean) => callback(connected); realtimeStatusListeners.push(wrapper); callback(isRealtimeConnected()); return () => { realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== wrapper); }; } export function onRealtimeStatus(callback: (connected: boolean, transport: RealtimeTransport) => void): () => void { realtimeStatusListeners.push(callback); callback(isRealtimeConnected(), activeTransport); return () => { realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== callback); }; } let isWsBlockedByProxy = false; let sseReconnectTimer: any = null; let sseReconnectDelay = 2000; const globalSubscribedRooms = new Set(); function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) { isRealtimeConnectedState = connected; activeTransport = transport; realtimeStatusListeners.forEach((fn) => { try { fn(connected, transport); } catch (e) { console.error('Realtime status listener error', e); } }); window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } })); } export function connectSSE(rooms?: string[]): void { if (rooms) { rooms.forEach((r) => globalSubscribedRooms.add(r)); } clearTimeout(sseReconnectTimer); clearTimeout(wsReconnectTimer); clearTimeout(wsConnectionWatchdog); if (activeGlobalSSE) { try { activeGlobalSSE.close(); } catch {} activeGlobalSSE = null; } try { const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms)); const es = new EventSource(url); activeGlobalSSE = es; es.onopen = () => { console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).'); sseReconnectDelay = 2000; notifyRealtimeStatusChange(true, 'sse'); if (activeSSEId && globalSubscribedRooms.size > 0) { globalSubscribedRooms.forEach((room) => { api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {}); }); } }; es.onmessage = (event) => { try { const msg = JSON.parse(event.data) as WSMessage; if (msg.event === 'SSE_CONNECTED') { activeSSEId = msg.payload?.sseId || null; if (activeSSEId && globalSubscribedRooms.size > 0) { globalSubscribedRooms.forEach((room) => { api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {}); }); } } wsListeners.forEach((fn) => { try { fn(msg); } catch (e) { console.error('SSE listener error', e); } }); window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg })); } catch (err) { console.error('Failed to parse SSE message', err); } }; es.onerror = (e) => { if (activeTransport === 'sse') { notifyRealtimeStatusChange(false, 'none'); } try { es.close(); } catch {} activeGlobalSSE = null; if (!manualDisconnect && getToken()) { clearTimeout(sseReconnectTimer); const delay = sseReconnectDelay; sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5)); sseReconnectTimer = setTimeout(() => { if (!manualDisconnect && getToken()) { if (isWsBlockedByProxy) { connectSSE(); } else { connectWebSocket(); } } }, delay); } }; } catch (err) { console.error('Failed to initialize EventSource SSE', err); notifyRealtimeStatusChange(false, 'none'); } } export function connectWebSocket(): void { const token = getToken(); if (!token) return; manualDisconnect = false; // If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE if (isWsBlockedByProxy) { connectSSE(); return; } if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) { return; } clearTimeout(wsConnectionWatchdog); clearTimeout(wsReconnectTimer); clearTimeout(sseReconnectTimer); try { const url = buildWebSocketURL(); const ws = new WebSocket(url); activeGlobalWS = ws; // 3.5s watchdog for corporate proxies silently stripping Upgrade header wsConnectionWatchdog = setTimeout(() => { if (ws && ws.readyState !== WebSocket.OPEN) { console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...'); isWsBlockedByProxy = true; try { ws.close(); } catch {} activeGlobalWS = null; connectSSE(); } }, 3500); ws.onopen = () => { clearTimeout(wsConnectionWatchdog); if (activeGlobalSSE) { try { activeGlobalSSE.close(); } catch {} activeGlobalSSE = null; } wsReconnectDelay = 2000; notifyRealtimeStatusChange(true, 'ws'); if (globalSubscribedRooms.size > 0) { globalSubscribedRooms.forEach((room) => { try { ws.send(JSON.stringify({ event: 'JOIN_ROOM', room })); } catch {} }); } }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data) as WSMessage; wsListeners.forEach((fn) => { try { fn(msg); } catch (e) { console.error('WS listener error', e); } }); window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg })); } catch (err) { console.error('Failed to parse WS message', err); } }; ws.onclose = (evt: CloseEvent) => { clearTimeout(wsConnectionWatchdog); activeGlobalWS = null; const isProxyIssue = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008; if (isProxyIssue && !manualDisconnect && getToken()) { console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle dauerhaft zu SSE...'); isWsBlockedByProxy = true; connectSSE(); return; } if (activeTransport === 'ws') { notifyRealtimeStatusChange(false, 'none'); } if (!manualDisconnect && getToken() && activeTransport !== 'sse') { clearTimeout(wsReconnectTimer); const delay = wsReconnectDelay; wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5)); wsReconnectTimer = setTimeout(() => { if (!manualDisconnect && getToken()) { connectWebSocket(); } }, delay); } }; ws.onerror = (e) => { clearTimeout(wsConnectionWatchdog); try { ws.close(); } catch {} }; } catch (e) { clearTimeout(wsConnectionWatchdog); isWsBlockedByProxy = true; connectSSE(); } } // Ensure reconnect when browser recovers from sleep or tab visibility if (typeof window !== 'undefined') { window.addEventListener('online', () => { if (getToken() && !isRealtimeConnected()) { if (isWsBlockedByProxy) { connectSSE(); } else { connectWebSocket(); } } }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) { if (isWsBlockedByProxy) { connectSSE(); } else { connectWebSocket(); } } }); } export function disconnectWebSocket(): void { manualDisconnect = true; clearTimeout(wsReconnectTimer); clearTimeout(wsConnectionWatchdog); clearTimeout(sseReconnectTimer); if (activeGlobalWS) { try { activeGlobalWS.close(); } catch {} activeGlobalWS = null; } if (activeGlobalSSE) { try { activeGlobalSSE.close(); } catch {} activeGlobalSSE = null; } notifyRealtimeStatusChange(false, 'none'); } export function sendWebSocketMessage(msg: any): void { if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) { activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg)); return; } // SSE Fallback: dispatch via REST endpoints try { const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg; if (parsed.event === 'CRDT_UPDATE' && parsed.docId) { api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }).catch(() => {}); } else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) { api.sendDocumentAwareness(parsed.docId, parsed.payload).catch(() => {}); } else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) { api.clearDocumentAwareness(parsed.docId).catch(() => {}); if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave').catch(() => {}); } else if (parsed.event === 'CRDT_JOIN' && parsed.docId) { if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join').catch(() => {}); } else if (parsed.event === 'JOIN_ROOM' && parsed.room) { if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {}); } else if (parsed.event === 'LEAVE_ROOM' && parsed.room) { if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {}); } else if (parsed.event === 'LOCK_DOC' && parsed.docId) { api.lockDocument(parsed.docId).catch(() => {}); } else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) { api.unlockDocument(parsed.docId).catch(() => {}); } } catch (err) { console.error('Error dispatching fallback REST message for SSE:', err); } } export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => void { wsListeners.push(callback); return () => { wsListeners = wsListeners.filter((fn) => fn !== callback); }; } export function createRealtimeConnection(options: { listId?: string; docId?: string; rooms?: string[]; onMessage?: (msg: WSMessage) => void; onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void; }) { let isClosed = false; const joinedRooms: string[] = []; if (options.docId) joinedRooms.push(`doc:${options.docId}`); if (options.listId) joinedRooms.push(options.listId); if (options.rooms) { options.rooms.forEach((r) => { if (!joinedRooms.includes(r)) joinedRooms.push(r); }); } joinedRooms.forEach((r) => globalSubscribedRooms.add(r)); // Subscribe to status changes const unsubStatus = onRealtimeStatus((connected, transport) => { if (isClosed) return; options.onStatusChange?.({ connected, transport, reason: connected ? undefined : 'Verbindung getrennt', }); }); // Subscribe to messages let unsubMsg: (() => void) | null = null; if (options.onMessage) { unsubMsg = onWebSocketMessage((msg) => { if (isClosed) return; options.onMessage?.(msg); }); } // Join rooms on the active connection const joinAll = () => { joinedRooms.forEach((r) => { if (r.startsWith('doc:')) { sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) }); } else { sendWebSocketMessage({ event: 'JOIN_ROOM', room: r }); } }); }; if (isRealtimeConnected()) { joinAll(); } const unsubRejoin = onRealtimeStatus((connected) => { if (isClosed) return; if (connected) { joinAll(); } }); return { getTransport: () => activeTransport, isConnected: () => isRealtimeConnected(), send: (msg: any) => { if (isClosed) return; sendWebSocketMessage(msg); }, close: () => { isClosed = true; unsubStatus(); unsubRejoin(); if (unsubMsg) unsubMsg(); joinedRooms.forEach((r) => { globalSubscribedRooms.delete(r); if (r.startsWith('doc:')) { sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) }); } else { sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r }); } }); }, }; }