feat: implement live-sync for tasks, workspace note sharing, and document locking system
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
This commit is contained in:
1 parent
dccf1a91e3
commit
84e0b6a4bd
10 files changed
+1773
-110
No files matched your search
+150
-2
@@ -4,6 +4,8 @@ import {
|
||||
DashboardStats,
|
||||
Document,
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
DocLockInfo,
|
||||
ListItem,
|
||||
Organization,
|
||||
PriorityLevel,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
SubTask,
|
||||
User,
|
||||
UserInvite,
|
||||
WSMessage,
|
||||
} from './types';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
@@ -428,6 +431,69 @@ export const api = {
|
||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- 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; 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');
|
||||
@@ -700,8 +766,90 @@ export function getOfflineQueue(): Thought[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebSocketURL(spaceId: string): string {
|
||||
let activeGlobalWS: WebSocket | null = null;
|
||||
let wsReconnectTimer: any = null;
|
||||
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
||||
let manualDisconnect = false;
|
||||
|
||||
export function buildWebSocketURL(spaceId?: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getToken() || '';
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
|
||||
if (spaceId) {
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
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.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;
|
||||
if (!manualDisconnect && getToken()) {
|
||||
clearTimeout(wsReconnectTimer);
|
||||
wsReconnectTimer = setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to connect WebSocket', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectWebSocket(): void {
|
||||
manualDisconnect = true;
|
||||
clearTimeout(wsReconnectTimer);
|
||||
if (activeGlobalWS) {
|
||||
try {
|
||||
activeGlobalWS.close();
|
||||
} catch {}
|
||||
activeGlobalWS = null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -128,6 +128,24 @@ export interface DocumentBlock {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface DocShare {
|
||||
id: string;
|
||||
docId: string;
|
||||
userId: string;
|
||||
userEmail?: string;
|
||||
displayName?: string;
|
||||
permission: 'read' | 'write';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DocLockInfo {
|
||||
docId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -142,6 +160,12 @@ export interface Document {
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// Collaboration & Permissions
|
||||
permission?: 'owner' | 'write' | 'read';
|
||||
isOwner?: boolean;
|
||||
ownerName?: string;
|
||||
sharedWith?: DocShare[];
|
||||
}
|
||||
|
||||
export interface SpaceMember {
|
||||
@@ -212,8 +236,8 @@ export interface DashboardStats {
|
||||
}
|
||||
|
||||
export interface WSMessage<T = any> {
|
||||
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'LIST_CLEARED' | 'LIST_CREATED' | 'LIST_DELETED' | 'USER_PRESENCE';
|
||||
space_id: string;
|
||||
event: string;
|
||||
space_id?: string;
|
||||
list_id?: string;
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
|
||||
Reference in new issue
Block a user