Files
MindSift/frontend/src/lib/api.ts
T
sandrodach 2f92e1f49a
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
feat(workspace): live synchronisation, verbindungsindikator, leere ordner persistenz und sperrkonfliktschutz
2026-10-26 10:18:02 +01:00

1154 lines
32 KiB
TypeScript

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<T>(val: T[] | null | undefined): T[] {
return Array.isArray(val) ? val : [];
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
...(options.headers as Record<string, string>),
};
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<AuthStatus> {
return request<AuthStatus>('/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<User> {
return request<User>('/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<DashboardStats> {
return request<DashboardStats>('/api/v1/stats');
},
async getUsersDirectory(): Promise<User[]> {
const res = await request<User[]>('/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<Thought> {
try {
const thought = await request<Thought>('/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<number> {
const queue = getOfflineQueue();
if (queue.length === 0) return 0;
let synced = 0;
const remaining: Thought[] = [];
for (const item of queue) {
try {
await request<Thought>('/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<Thought[]> {
const res = await request<Thought[]>('/api/v1/triage/deck');
return ensureArray(res);
},
async getThoughts(status = 'all'): Promise<Thought[]> {
const res = await request<Thought[]>(`/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<Thought> {
return request<Thought>(`/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<Thought[]> {
const query = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
const res = await request<Thought[]>(`/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<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
},
async deleteThought(thoughtId: string): Promise<void> {
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<TaskList[]> {
const res = await request<TaskList[]>('/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<TaskList> {
return request<TaskList>('/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<TaskList> {
return request<TaskList>(`/api/v1/task-lists/${id}`, {
method: 'PUT',
body: JSON.stringify(patch),
});
},
async deleteTaskList(id: string): Promise<void> {
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' });
},
async getTaskListShares(id: string): Promise<TaskListShare[]> {
const res = await request<TaskListShare[]>(`/api/v1/task-lists/${id}/shares`);
return ensureArray(res);
},
async addTaskListShare(
id: string,
payload: { targetType: 'team' | 'user'; targetId: string }
): Promise<TaskListShare> {
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async removeTaskListShare(id: string, shareId: string): Promise<void> {
await request(`/api/v1/task-lists/${id}/shares/${shareId}`, { method: 'DELETE' });
},
// --- WORKSPACE FOLDERS ---
async getFolders(): Promise<string[]> {
const res = await request<string[]>('/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' }
);
},
// --- WORKSPACE SHARED FOLDERS ---
async getSharedFolders(): Promise<SharedFolder[]> {
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
return ensureArray(res);
},
async createSharedFolder(payload: { name: string; mountPath?: string }): Promise<SharedFolder> {
return request<SharedFolder>('/api/v1/workspace/folders/shared', {
method: 'POST',
body: JSON.stringify(payload),
});
},
async getSharedFolder(id: string): Promise<SharedFolder> {
return request<SharedFolder>(`/api/v1/workspace/folders/shared/${id}`);
},
async deleteSharedFolder(id: string): Promise<void> {
await request(`/api/v1/workspace/folders/shared/${id}`, { method: 'DELETE' });
},
async updateFolderMount(id: string, mountPath: string): Promise<void> {
await request(`/api/v1/workspace/folders/shared/${id}/mount`, {
method: 'PUT',
body: JSON.stringify({ mountPath }),
});
},
async getFolderShares(folderId: string): Promise<FolderShare[]> {
const res = await request<FolderShare[]>(`/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<FolderShare> {
return request<FolderShare>(`/api/v1/workspace/folders/shared/${folderId}/shares`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async deleteFolderShare(folderId: string, shareId: string): Promise<void> {
await request(`/api/v1/workspace/folders/shared/${folderId}/shares/${shareId}`, { method: 'DELETE' });
},
// --- WORKSPACE TAGS ---
async getWorkspaceTags(): Promise<TagSummary[]> {
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
return ensureArray(res);
},
async getTagDetails(tag: string): Promise<TagDetails> {
const clean = encodeURIComponent(tag.replace(/^#/, ''));
const res = await request<TagDetails>(`/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<Document[]> {
const res = await request<Document[]>('/api/v1/documents');
return ensureArray(res).map((d) => ({
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
}));
},
async getDocument(id: string): Promise<Document> {
const d = await request<Document>(`/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<Document> {
const d = await request<Document>('/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<Document> {
const d = await request<Document>(`/api/v1/documents/${id}`, {
method: 'PUT',
body: JSON.stringify(patch),
});
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
},
async deleteDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
async moveDocument(id: string, folderPath: string): Promise<Document> {
const d = await request<Document>(`/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<DocShare[]> {
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
return ensureArray(res);
},
async createDocShare(
docId: string,
payload: { userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
): Promise<DocShare> {
return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async deleteDocShare(docId: string, userId: string): Promise<void> {
await request(`/api/v1/documents/${docId}/shares/${userId}`, { method: 'DELETE' });
},
async getCollaborators(): Promise<User[]> {
const res = await request<User[]>('/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<Space[]> {
const res = await request<Space[]>('/api/v1/spaces');
return ensureArray(res).map((s) => ({
...s,
members: ensureArray(s.members),
shares: ensureArray(s.shares),
}));
},
async getSpaceShares(spaceId: string): Promise<SpaceShare[]> {
const res = await request<SpaceShare[]>(`/api/v1/spaces/${spaceId}/shares`);
return ensureArray(res);
},
async createSpace(name: string, icon: string, description: string): Promise<Space> {
return request<Space>('/api/v1/spaces', {
method: 'POST',
body: JSON.stringify({ name, icon, description }),
});
},
async joinSpace(inviteCode: string): Promise<Space> {
return request<Space>('/api/v1/spaces/join', {
method: 'POST',
body: JSON.stringify({ inviteCode }),
});
},
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
method: 'POST',
body: JSON.stringify(target),
});
},
async removeSpaceMember(spaceId: string, userId: string): Promise<void> {
await request(`/api/v1/spaces/${spaceId}/members/${userId}`, {
method: 'DELETE',
});
},
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
return ensureArray(res).map((l) => ({
...l,
items: ensureArray(l.items),
}));
},
async getAllLists(): Promise<SharedList[]> {
const res = await request<SharedList[]>('/api/v1/lists');
return ensureArray(res).map((l) => ({
...l,
items: ensureArray(l.items),
shares: ensureArray(l.shares),
}));
},
async getListById(listId: string): Promise<SharedList> {
const l = await request<SharedList>(`/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<SharedList> {
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<SharedList>('/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<void> {
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
},
async getListShares(listId: string): Promise<ListShare[]> {
const res = await request<ListShare[]>(`/api/v1/lists/${listId}/shares`);
return ensureArray(res);
},
async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise<ListShare> {
return request<ListShare>(`/api/v1/lists/${listId}/shares`, {
method: 'POST',
body: JSON.stringify(target),
});
},
async removeListShare(listId: string, shareId: string): Promise<void> {
await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' });
},
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
method: 'POST',
body: JSON.stringify({ content, category, quantity }),
});
},
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ isCompleted }),
});
},
async deleteListItem(listId: string, itemId: string): Promise<void> {
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<Organization[]> {
const res = await request<Organization[]>('/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<Organization> {
return request<Organization>('/api/v1/organizations', {
method: 'POST',
body: JSON.stringify({ name }),
});
},
async deleteOrganization(id: string): Promise<void> {
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
},
async getOrgTeams(orgId: string): Promise<Team[]> {
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
},
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
return request<Team>(`/api/v1/organizations/${orgId}/teams`, {
method: 'POST',
body: JSON.stringify({ name, description }),
});
},
async createTeam(name: string, description = ''): Promise<Team> {
return request<Team>('/api/v1/teams', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
},
async updateTeam(teamId: string, name: string, description = ''): Promise<Team> {
return request<Team>(`/api/v1/teams/${teamId}`, {
method: 'PUT',
body: JSON.stringify({ name, description }),
});
},
async deleteTeam(teamId: string): Promise<void> {
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
},
async getUserTeams(): Promise<Team[]> {
const res = await request<Team[]>('/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<void> {
await request(`/api/v1/teams/${teamId}/members`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async updateTeamMemberRole(
teamId: string,
userId: string,
role: 'admin' | 'member'
): Promise<void> {
await request(`/api/v1/teams/${teamId}/members/${userId}`, {
method: 'PUT',
body: JSON.stringify({ role }),
});
},
async removeTeamMember(teamId: string, userId: string): Promise<void> {
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
},
// --- ADMIN & STAFF PANEL ---
async adminGetUsers(): Promise<User[]> {
const res = await request<User[]>('/api/v1/admin/users');
return ensureArray(res);
},
async adminCreateUser(payload: {
email: string;
password: string;
displayName: string;
role: string;
}): Promise<User> {
return request<User>('/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<User> {
return request<User>(`/api/v1/admin/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
},
async adminResetPassword(id: string, password: string): Promise<void> {
await request(`/api/v1/admin/users/${id}/password`, {
method: 'PUT',
body: JSON.stringify({ password }),
});
},
async adminDeleteUser(id: string): Promise<void> {
await request(`/api/v1/admin/users/${id}`, { method: 'DELETE' });
},
async adminGetInvites(): Promise<UserInvite[]> {
const res = await request<UserInvite[]>('/api/v1/admin/invites');
return ensureArray(res);
},
async adminCreateInvite(payload: {
email?: string;
role: string;
teamId?: string;
}): Promise<UserInvite> {
return request<UserInvite>('/api/v1/admin/invites', {
method: 'POST',
body: JSON.stringify(payload),
});
},
async adminDeleteInvite(id: string): Promise<void> {
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
},
async adminGetSettings(): Promise<SystemSettings> {
return request<SystemSettings>('/api/v1/admin/settings');
},
async adminUpdateSettings(settings: SystemSettings): Promise<SystemSettings> {
return request<SystemSettings>('/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<APIKey[]> {
const res = await request<APIKey[]>('/api/v1/keys');
return ensureArray(res);
},
async createAPIKey(name: string): Promise<APIKey> {
return request<APIKey>('/api/v1/keys', {
method: 'POST',
body: JSON.stringify({ name }),
});
},
async deleteAPIKey(id: string): Promise<void> {
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<any> {
return request<any>('/api/v1/export');
},
};
export function getOfflineQueue(): Thought[] {
try {
const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
let activeGlobalWS: WebSocket | null = null;
let wsReconnectTimer: any = null;
let wsListeners: ((msg: WSMessage) => void)[] = [];
let wsStatusListeners: ((connected: boolean) => void)[] = [];
let isWSConnected = false;
let manualDisconnect = false;
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 isWebSocketConnected(): boolean {
return isWSConnected && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
}
export function onWebSocketStatus(callback: (connected: boolean) => void): () => void {
wsStatusListeners.push(callback);
callback(isWebSocketConnected());
return () => {
wsStatusListeners = wsStatusListeners.filter((fn) => fn !== callback);
};
}
function notifyStatusChange(connected: boolean) {
isWSConnected = connected;
wsStatusListeners.forEach((fn) => {
try {
fn(connected);
} catch (e) {
console.error('WS status listener error', e);
}
});
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected } }));
}
export function connectWebSocket(): void {
const token = getToken();
if (!token) return;
manualDisconnect = false;
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
return;
}
try {
const url = buildWebSocketURL();
const ws = new WebSocket(url);
activeGlobalWS = ws;
ws.onopen = () => {
notifyStatusChange(true);
};
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 = () => {
activeGlobalWS = null;
notifyStatusChange(false);
if (!manualDisconnect && getToken()) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(() => {
connectWebSocket();
}, 3000);
}
};
ws.onerror = () => {
notifyStatusChange(false);
try {
ws.close();
} catch {}
};
} catch (e) {
console.error('Failed to connect WebSocket', e);
notifyStatusChange(false);
}
}
// Ensure reconnect when browser recovers from sleep or tab visibility
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
if (getToken() && !isWebSocketConnected()) {
connectWebSocket();
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && getToken() && !isWebSocketConnected()) {
connectWebSocket();
}
});
}
export function disconnectWebSocket(): void {
manualDisconnect = true;
clearTimeout(wsReconnectTimer);
if (activeGlobalWS) {
try {
activeGlobalWS.close();
} catch {}
activeGlobalWS = null;
}
notifyStatusChange(false);
}
export function sendWebSocketMessage(msg: any): void {
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
}
}
export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => void {
wsListeners.push(callback);
return () => {
wsListeners = wsListeners.filter((fn) => fn !== callback);
};
}