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
+39
-1
@@ -14,7 +14,14 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||||
import { api, clearToken, getToken, triggerHaptic } from './lib/api';
|
import {
|
||||||
|
api,
|
||||||
|
clearToken,
|
||||||
|
getToken,
|
||||||
|
triggerHaptic,
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket,
|
||||||
|
} from './lib/api';
|
||||||
import {
|
import {
|
||||||
AVAILABLE_THEMES,
|
AVAILABLE_THEMES,
|
||||||
initThemeOnBoot,
|
initThemeOnBoot,
|
||||||
@@ -193,6 +200,35 @@ export const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// WebSocket Connection Management
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && getToken()) {
|
||||||
|
connectWebSocket();
|
||||||
|
} else {
|
||||||
|
disconnectWebSocket();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Refresh stats on task/doc real-time events
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWs = (e: any) => {
|
||||||
|
const msg = e.detail;
|
||||||
|
if (!msg) return;
|
||||||
|
if (
|
||||||
|
typeof msg.event === 'string' &&
|
||||||
|
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_'))
|
||||||
|
) {
|
||||||
|
api.getMe().then((res) => {
|
||||||
|
setStats(res.stats);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('mindsift:ws', handleWs);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mindsift:ws', handleWs);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Close profile dropdown on outside click
|
// Close profile dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
@@ -239,6 +275,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
|
disconnectWebSocket();
|
||||||
clearToken();
|
clearToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setShowProfileMenu(false);
|
setShowProfileMenu(false);
|
||||||
@@ -551,6 +588,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
{activeTab === 'workspace' && (
|
{activeTab === 'workspace' && (
|
||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
|
currentUser={user}
|
||||||
initialDocId={selectedDocId}
|
initialDocId={selectedDocId}
|
||||||
onStatsChange={refreshAll}
|
onStatsChange={refreshAll}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -96,6 +96,71 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
loadData();
|
loadData();
|
||||||
}, [refreshTrigger, selectedListId]);
|
}, [refreshTrigger, selectedListId]);
|
||||||
|
|
||||||
|
// Live-Sync WebSocket listener for tasks
|
||||||
|
useEffect(() => {
|
||||||
|
const handleWs = (e: any) => {
|
||||||
|
const msg = e.detail;
|
||||||
|
if (!msg || !msg.event) return;
|
||||||
|
|
||||||
|
if (msg.event === 'TASK_CREATED') {
|
||||||
|
const created = msg.payload as Thought;
|
||||||
|
if (
|
||||||
|
created &&
|
||||||
|
(created.status === 'converted' ||
|
||||||
|
created.kanbanStatus ||
|
||||||
|
created.taskListId ||
|
||||||
|
created.isCompleted !== undefined)
|
||||||
|
) {
|
||||||
|
setTasks((prev) => {
|
||||||
|
if (prev.some((t) => t.id === created.id)) return prev;
|
||||||
|
if (selectedListId !== 'all' && created.taskListId !== selectedListId) return prev;
|
||||||
|
return [created, ...prev];
|
||||||
|
});
|
||||||
|
onRefreshStats();
|
||||||
|
}
|
||||||
|
} else if (msg.event === 'TASK_UPDATED') {
|
||||||
|
const updated = msg.payload as Thought;
|
||||||
|
if (updated && updated.id) {
|
||||||
|
setTasks((prev) => {
|
||||||
|
const exists = prev.some((t) => t.id === updated.id);
|
||||||
|
if (!exists) {
|
||||||
|
if (
|
||||||
|
(updated.status === 'converted' ||
|
||||||
|
updated.kanbanStatus ||
|
||||||
|
updated.taskListId ||
|
||||||
|
updated.isCompleted !== undefined) &&
|
||||||
|
(selectedListId === 'all' || updated.taskListId === selectedListId)
|
||||||
|
) {
|
||||||
|
return [updated, ...prev];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (selectedListId !== 'all' && updated.taskListId !== selectedListId) {
|
||||||
|
return prev.filter((t) => t.id !== updated.id);
|
||||||
|
}
|
||||||
|
return prev.map((t) => {
|
||||||
|
if (t.id !== updated.id) return t;
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
notes: activeNotesTaskId === updated.id ? t.notes : updated.notes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
onRefreshStats();
|
||||||
|
}
|
||||||
|
} else if (msg.event === 'TASK_DELETED') {
|
||||||
|
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
|
||||||
|
if (deletedId) {
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
|
||||||
|
onRefreshStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mindsift:ws', handleWs);
|
||||||
|
return () => window.removeEventListener('mindsift:ws', handleWs);
|
||||||
|
}, [selectedListId, activeNotesTaskId, onRefreshStats]);
|
||||||
|
|
||||||
const handleToggleDone = async (task: Thought) => {
|
const handleToggleDone = async (task: Thought) => {
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
const nextState = !task.isCompleted;
|
const nextState = !task.isCompleted;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+150
-2
@@ -4,6 +4,8 @@ import {
|
|||||||
DashboardStats,
|
DashboardStats,
|
||||||
Document,
|
Document,
|
||||||
DocumentBlock,
|
DocumentBlock,
|
||||||
|
DocShare,
|
||||||
|
DocLockInfo,
|
||||||
ListItem,
|
ListItem,
|
||||||
Organization,
|
Organization,
|
||||||
PriorityLevel,
|
PriorityLevel,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
SubTask,
|
SubTask,
|
||||||
User,
|
User,
|
||||||
UserInvite,
|
UserInvite,
|
||||||
|
WSMessage,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||||
@@ -428,6 +431,69 @@ export const api = {
|
|||||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
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 ---
|
// --- SPACES & REALTIME LISTS ---
|
||||||
async getSpaces(): Promise<Space[]> {
|
async getSpaces(): Promise<Space[]> {
|
||||||
const res = await request<Space[]>('/api/v1/spaces');
|
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 protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const token = getToken() || '';
|
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;
|
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 {
|
export interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -142,6 +160,12 @@ export interface Document {
|
|||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
||||||
|
// Collaboration & Permissions
|
||||||
|
permission?: 'owner' | 'write' | 'read';
|
||||||
|
isOwner?: boolean;
|
||||||
|
ownerName?: string;
|
||||||
|
sharedWith?: DocShare[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpaceMember {
|
export interface SpaceMember {
|
||||||
@@ -212,8 +236,8 @@ export interface DashboardStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WSMessage<T = any> {
|
export interface WSMessage<T = any> {
|
||||||
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'LIST_CLEARED' | 'LIST_CREATED' | 'LIST_DELETED' | 'USER_PRESENCE';
|
event: string;
|
||||||
space_id: string;
|
space_id?: string;
|
||||||
list_id?: string;
|
list_id?: string;
|
||||||
payload: T;
|
payload: T;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||||||
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
||||||
|
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||||
|
|
||||||
// Authenticated API Routes
|
// Authenticated API Routes
|
||||||
@@ -71,6 +72,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||||||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||||||
|
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
|
||||||
r.Get("/api/v1/stats", s.handleStats)
|
r.Get("/api/v1/stats", s.handleStats)
|
||||||
r.Get("/api/v1/search", s.handleSearch)
|
r.Get("/api/v1/search", s.handleSearch)
|
||||||
r.Get("/api/v1/export", s.handleExport)
|
r.Get("/api/v1/export", s.handleExport)
|
||||||
@@ -100,6 +102,17 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
|
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
|
||||||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||||||
|
|
||||||
|
// Document Sharing & Collaboration
|
||||||
|
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
|
||||||
|
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
||||||
|
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
||||||
|
|
||||||
|
// Document Locking
|
||||||
|
r.Get("/api/v1/documents/{id}/lock", s.handleGetDocLock)
|
||||||
|
r.Post("/api/v1/documents/{id}/lock", s.handleLockDoc)
|
||||||
|
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
|
||||||
|
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
|
||||||
|
|
||||||
// Shared Spaces & Realtime Lists
|
// Shared Spaces & Realtime Lists
|
||||||
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
||||||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||||||
@@ -544,6 +557,7 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought)
|
||||||
writeJSON(w, http.StatusCreated, thought)
|
writeJSON(w, http.StatusCreated, thought)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +632,7 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +671,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +682,7 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastTaskEvent(userID, "TASK_DELETED", map[string]string{"id": thoughtID})
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,6 +729,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc)
|
||||||
writeJSON(w, http.StatusCreated, doc)
|
writeJSON(w, http.StatusCreated, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,6 +747,17 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Check document lock
|
||||||
|
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
|
||||||
|
w.WriteHeader(http.StatusLocked)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
|
||||||
|
"currentLock": lock,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Icon *string `json:"icon"`
|
Icon *string `json:"icon"`
|
||||||
@@ -747,6 +776,7 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
|
||||||
writeJSON(w, http.StatusOK, doc)
|
writeJSON(w, http.StatusOK, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,9 +787,135 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- DOCUMENT SHARING & COLLABORATION ---
|
||||||
|
|
||||||
|
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
shares, err := s.store.GetDocShares(docID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
var req struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Permission string `json:"permission"` // read | write
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := req.UserID
|
||||||
|
if target == "" {
|
||||||
|
target = req.Email
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := s.store.CreateDocShare(userID, docID, target, req.Permission)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share)
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
|
||||||
|
writeJSON(w, http.StatusCreated, share)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
targetUserID := chi.URLParam(r, "userId")
|
||||||
|
if err := s.store.DeleteDocShare(userID, docID, targetUserID); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastToUser(targetUserID, "DOC_UNSHARED", map[string]string{"docId": docID})
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_UNSHARED", map[string]string{"docId": docID, "userId": targetUserID})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
users, err := s.store.GetCollaborators(userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DOCUMENT LOCKING ---
|
||||||
|
|
||||||
|
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
lock := s.wsHub.GetDocLock(docID)
|
||||||
|
if lock == nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": true, "lock": lock})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
user, err := s.store.GetUserByID(userID)
|
||||||
|
userName := "Ein Benutzer"
|
||||||
|
if err == nil && user != nil && user.DisplayName != "" {
|
||||||
|
userName = user.DisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 30*time.Second)
|
||||||
|
if !acquired {
|
||||||
|
w.WriteHeader(http.StatusLocked)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"locked": true,
|
||||||
|
"acquired": false,
|
||||||
|
"currentLock": currentLock,
|
||||||
|
"message": "Wird aktuell bearbeitet von " + currentLock.UserName,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"locked": true,
|
||||||
|
"acquired": true,
|
||||||
|
"lock": info,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
ok := s.wsHub.HeartbeatDocLock(docID, userID, 30*time.Second)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
docID := chi.URLParam(r, "id")
|
||||||
|
released := s.wsHub.ReleaseDocLock(docID, userID)
|
||||||
|
if released {
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
|
||||||
|
}
|
||||||
|
|
||||||
// --- SPACES & REALTIME LISTS ---
|
// --- SPACES & REALTIME LISTS ---
|
||||||
|
|
||||||
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -955,6 +1111,11 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
|
|||||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := chi.URLParam(r, "spaceId")
|
spaceID := chi.URLParam(r, "spaceId")
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||||
|
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||||
|
}
|
||||||
|
}
|
||||||
userID := "anonymous"
|
userID := "anonymous"
|
||||||
if token != "" {
|
if token != "" {
|
||||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||||
|
|||||||
@@ -221,6 +221,24 @@ type DocumentBlock struct {
|
|||||||
Collapsed bool `json:"collapsed,omitempty"`
|
Collapsed bool `json:"collapsed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocShare struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
UserEmail string `json:"userEmail,omitempty"`
|
||||||
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
|
Permission string `json:"permission"` // read | write
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocLockInfo struct {
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type Document struct {
|
type Document struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
@@ -235,6 +253,12 @@ type Document struct {
|
|||||||
IsArchived bool `json:"isArchived"`
|
IsArchived bool `json:"isArchived"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
|
||||||
|
// Collaboration & Permissions
|
||||||
|
Permission string `json:"permission"` // owner | write | read
|
||||||
|
IsOwner bool `json:"isOwner"`
|
||||||
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
|
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpaceMember struct {
|
type SpaceMember struct {
|
||||||
|
|||||||
+243
-6
@@ -199,6 +199,21 @@ func (s *Store) migrate() error {
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS doc_shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
doc_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
permission TEXT NOT NULL CHECK(permission IN ('read', 'write')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
UNIQUE(doc_id, user_id),
|
||||||
|
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lists (
|
CREATE TABLE IF NOT EXISTS lists (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
space_id TEXT NOT NULL,
|
space_id TEXT NOT NULL,
|
||||||
@@ -267,6 +282,10 @@ func (s *Store) migrate() error {
|
|||||||
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
|
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
|
||||||
|
|
||||||
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS doc_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, user_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, user_id));",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);",
|
||||||
}
|
}
|
||||||
for _, q := range columnMigrations {
|
for _, q := range columnMigrations {
|
||||||
_, _ = s.db.Exec(q)
|
_, _ = s.db.Exec(q)
|
||||||
@@ -1099,6 +1118,7 @@ func scanDocument(rows interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
||||||
|
// 1. Fetch user's own documents
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at
|
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at
|
||||||
FROM documents WHERE user_id = ? AND is_archived = 0
|
FROM documents WHERE user_id = ? AND is_archived = 0
|
||||||
@@ -1108,25 +1128,119 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
docs := make([]domain.Document, 0)
|
docs := make([]domain.Document, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
d, err := scanDocument(rows)
|
d, err := scanDocument(rows)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
d.Permission = "owner"
|
||||||
|
d.IsOwner = true
|
||||||
docs = append(docs, *d)
|
docs = append(docs, *d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
// 2. Fetch documents shared with the user
|
||||||
|
sharedRows, err := s.db.Query(
|
||||||
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, d.is_pinned, d.is_archived, d.created_at, d.updated_at,
|
||||||
|
s.permission, u.display_name
|
||||||
|
FROM documents d
|
||||||
|
JOIN doc_shares s ON d.id = s.doc_id
|
||||||
|
JOIN users u ON d.user_id = u.id
|
||||||
|
WHERE s.user_id = ? AND d.is_archived = 0
|
||||||
|
ORDER BY d.updated_at DESC`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
for sharedRows.Next() {
|
||||||
|
d := &domain.Document{}
|
||||||
|
var blocksStr, tagsStr string
|
||||||
|
var isPinned, isArchived int
|
||||||
|
var perm, ownerName string
|
||||||
|
if scanErr := sharedRows.Scan(
|
||||||
|
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||||
|
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||||
|
&perm, &ownerName,
|
||||||
|
); scanErr == nil {
|
||||||
|
d.IsPinned = isPinned != 0
|
||||||
|
d.IsArchived = isArchived != 0
|
||||||
|
d.Blocks = []domain.DocumentBlock{}
|
||||||
|
d.Tags = []string{}
|
||||||
|
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||||||
|
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||||
|
d.Permission = perm
|
||||||
|
d.IsOwner = false
|
||||||
|
d.OwnerName = ownerName
|
||||||
|
docs = append(docs, *d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = sharedRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Attach shares to owned documents
|
||||||
|
for i := range docs {
|
||||||
|
if docs[i].IsOwner {
|
||||||
|
if shares, err := s.GetDocShares(docs[i].ID); err == nil {
|
||||||
|
docs[i].SharedWith = shares
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return docs, nil
|
return docs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
||||||
|
// Try owned document first
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at
|
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at
|
||||||
FROM documents WHERE id = ? AND user_id = ?`,
|
FROM documents WHERE id = ? AND user_id = ?`,
|
||||||
docID, userID,
|
docID, userID,
|
||||||
)
|
)
|
||||||
return scanDocument(row)
|
doc, err := scanDocument(row)
|
||||||
|
if err == nil {
|
||||||
|
doc.Permission = "owner"
|
||||||
|
doc.IsOwner = true
|
||||||
|
if shares, err := s.GetDocShares(doc.ID); err == nil {
|
||||||
|
doc.SharedWith = shares
|
||||||
|
}
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try shared document
|
||||||
|
sharedRow := s.db.QueryRow(
|
||||||
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, d.is_pinned, d.is_archived, d.created_at, d.updated_at,
|
||||||
|
s.permission, u.display_name
|
||||||
|
FROM documents d
|
||||||
|
JOIN doc_shares s ON d.id = s.doc_id
|
||||||
|
JOIN users u ON d.user_id = u.id
|
||||||
|
WHERE d.id = ? AND s.user_id = ?`,
|
||||||
|
docID, userID,
|
||||||
|
)
|
||||||
|
d := &domain.Document{}
|
||||||
|
var blocksStr, tagsStr string
|
||||||
|
var isPinned, isArchived int
|
||||||
|
var perm, ownerName string
|
||||||
|
err = sharedRow.Scan(
|
||||||
|
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||||
|
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||||
|
&perm, &ownerName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.IsPinned = isPinned != 0
|
||||||
|
d.IsArchived = isArchived != 0
|
||||||
|
d.Blocks = []domain.DocumentBlock{}
|
||||||
|
d.Tags = []string{}
|
||||||
|
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||||||
|
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||||
|
d.Permission = perm
|
||||||
|
d.IsOwner = false
|
||||||
|
d.OwnerName = ownerName
|
||||||
|
if shares, err := s.GetDocShares(d.ID); err == nil {
|
||||||
|
d.SharedWith = shares
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) {
|
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) {
|
||||||
@@ -1135,6 +1249,10 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !doc.IsOwner && doc.Permission != "write" {
|
||||||
|
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||||
|
}
|
||||||
|
|
||||||
if title != nil {
|
if title != nil {
|
||||||
doc.Title = strings.TrimSpace(*title)
|
doc.Title = strings.TrimSpace(*title)
|
||||||
}
|
}
|
||||||
@@ -1172,8 +1290,8 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
|
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
`UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, is_pinned = ?, updated_at = ?
|
`UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, is_pinned = ?, updated_at = ?
|
||||||
WHERE id = ? AND user_id = ?`,
|
WHERE id = ?`,
|
||||||
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.UpdatedAt, docID, userID,
|
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.UpdatedAt, docID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1183,14 +1301,133 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteDocument(userID, docID string) error {
|
func (s *Store) DeleteDocument(userID, docID string) error {
|
||||||
doc, _ := s.GetDocumentByID(userID, docID)
|
doc, err := s.GetDocumentByID(userID, docID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !doc.IsOwner {
|
||||||
|
// Collaborator removing their shared access
|
||||||
|
return s.DeleteDocShare(userID, docID, userID)
|
||||||
|
}
|
||||||
if doc != nil {
|
if doc != nil {
|
||||||
s.removeDocumentFromDisk(doc)
|
s.removeDocumentFromDisk(doc)
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
|
||||||
|
FROM doc_shares s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.doc_id = ?
|
||||||
|
ORDER BY s.created_at ASC`,
|
||||||
|
docID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
shares := make([]domain.DocShare, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var sh domain.DocShare
|
||||||
|
if err := rows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||||
|
shares = append(shares, sh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shares, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
|
||||||
|
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !doc.IsOwner && doc.Permission != "write" {
|
||||||
|
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
||||||
|
}
|
||||||
|
|
||||||
|
if permission != "read" && permission != "write" {
|
||||||
|
permission = "read"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve target user by ID or Email
|
||||||
|
var targetUser *domain.User
|
||||||
|
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||||
|
if strings.Contains(cleanTarget, "@") {
|
||||||
|
targetUser, err = s.GetUserByEmail(cleanTarget)
|
||||||
|
} else {
|
||||||
|
targetUser, err = s.GetUserByID(cleanTarget)
|
||||||
|
}
|
||||||
|
if err != nil || targetUser == nil {
|
||||||
|
return nil, fmt.Errorf("benutzer nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetUser.ID == doc.UserID {
|
||||||
|
return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := domain.NowISO()
|
||||||
|
shareID := domain.NewID("share")
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(doc_id, user_id) DO UPDATE SET permission = excluded.permission`,
|
||||||
|
shareID, docID, targetUser.ID, permission, now, currentUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.DocShare{
|
||||||
|
ID: shareID,
|
||||||
|
DocID: docID,
|
||||||
|
UserID: targetUser.ID,
|
||||||
|
UserEmail: targetUser.Email,
|
||||||
|
DisplayName: targetUser.DisplayName,
|
||||||
|
Permission: permission,
|
||||||
|
CreatedBy: currentUserID,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteDocShare(currentUserID, docID, targetUserID string) error {
|
||||||
|
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetUserID {
|
||||||
|
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND user_id = ?`, docID, targetUserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, email, display_name, role, is_active, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`,
|
||||||
|
currentUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := make([]domain.User, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var u domain.User
|
||||||
|
var activeInt int
|
||||||
|
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.CreatedAt); err == nil {
|
||||||
|
u.IsActive = activeInt == 1
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- SPACES & REALTIME LISTS ---
|
// --- SPACES & REALTIME LISTS ---
|
||||||
|
|
||||||
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
|
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
|
||||||
|
|||||||
@@ -108,4 +108,57 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpdateSystemSettings failed: %v", err)
|
t.Fatalf("UpdateSystemSettings failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 8. Test Document Sharing & Permissions
|
||||||
|
user2, err := store.CreateUser("collab@example.com", "hash", "Collab User", "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser collab failed: %v", err)
|
||||||
|
}
|
||||||
|
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
|
||||||
|
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
||||||
|
}, []string{"test"}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share with user2 as "read"
|
||||||
|
share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||||
|
if err != nil || share.Permission != "read" {
|
||||||
|
t.Fatalf("CreateDocShare failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// user2 can get document
|
||||||
|
docForUser2, err := store.GetDocumentByID(user2.ID, newDoc.ID)
|
||||||
|
if err != nil || docForUser2.Permission != "read" || docForUser2.IsOwner {
|
||||||
|
t.Fatalf("GetDocumentByID for user2 failed or wrong permission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// user2 tries to edit read-only doc -> must fail
|
||||||
|
updatedTitle := "Hacked Title"
|
||||||
|
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade to write permission
|
||||||
|
_, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upgrade CreateDocShare failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// user2 edits writeable doc -> must succeed
|
||||||
|
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove share
|
||||||
|
err = store.DeleteDocShare(user.ID, newDoc.ID, user2.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteDocShare failed: %v", err)
|
||||||
|
}
|
||||||
|
_, err = store.GetDocumentByID(user2.ID, newDoc.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected GetDocumentByID to fail after share removed, but succeeded")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+287
-35
@@ -12,8 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 2048,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 2048,
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
return true // Self-hosted reverse proxy compatible
|
return true // Self-hosted reverse proxy compatible
|
||||||
},
|
},
|
||||||
@@ -27,53 +27,273 @@ type Client struct {
|
|||||||
send chan []byte
|
send chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type docLockItem struct {
|
||||||
|
docID string
|
||||||
|
userID string
|
||||||
|
userName string
|
||||||
|
lockedAt time.Time
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
rooms map[string]map[*Client]bool
|
spaceRooms map[string]map[*Client]bool
|
||||||
|
userRooms map[string]map[*Client]bool
|
||||||
|
allClients map[*Client]bool
|
||||||
|
locks map[string]*docLockItem
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub() *Hub {
|
func NewHub() *Hub {
|
||||||
return &Hub{
|
h := &Hub{
|
||||||
rooms: make(map[string]map[*Client]bool),
|
spaceRooms: make(map[string]map[*Client]bool),
|
||||||
|
userRooms: make(map[string]map[*Client]bool),
|
||||||
|
allClients: make(map[*Client]bool),
|
||||||
|
locks: make(map[string]*docLockItem),
|
||||||
}
|
}
|
||||||
|
go h.cleanupLocksLoop()
|
||||||
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Register(c *Client) {
|
func (h *Hub) Register(c *Client) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if h.rooms[c.spaceID] == nil {
|
h.allClients[c] = true
|
||||||
h.rooms[c.spaceID] = make(map[*Client]bool)
|
if c.spaceID != "" {
|
||||||
|
if h.spaceRooms[c.spaceID] == nil {
|
||||||
|
h.spaceRooms[c.spaceID] = make(map[*Client]bool)
|
||||||
|
}
|
||||||
|
h.spaceRooms[c.spaceID][c] = true
|
||||||
}
|
}
|
||||||
h.rooms[c.spaceID][c] = true
|
if c.userID != "" && c.userID != "anonymous" {
|
||||||
onlineCount := len(h.rooms[c.spaceID])
|
if h.userRooms[c.userID] == nil {
|
||||||
|
h.userRooms[c.userID] = make(map[*Client]bool)
|
||||||
|
}
|
||||||
|
h.userRooms[c.userID][c] = true
|
||||||
|
}
|
||||||
|
onlineCount := len(h.allClients)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
if c.spaceID != "" {
|
||||||
"onlineCount": onlineCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) Unregister(c *Client) {
|
|
||||||
h.mu.Lock()
|
|
||||||
var onlineCount int
|
|
||||||
if room, ok := h.rooms[c.spaceID]; ok {
|
|
||||||
if _, exists := room[c]; exists {
|
|
||||||
delete(room, c)
|
|
||||||
close(c.send)
|
|
||||||
}
|
|
||||||
onlineCount = len(room)
|
|
||||||
if onlineCount == 0 {
|
|
||||||
delete(h.rooms, c.spaceID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
if onlineCount > 0 {
|
|
||||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||||
"onlineCount": onlineCount,
|
"onlineCount": onlineCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Unregister(c *Client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.allClients, c)
|
||||||
|
if c.spaceID != "" {
|
||||||
|
if room, ok := h.spaceRooms[c.spaceID]; ok {
|
||||||
|
delete(room, c)
|
||||||
|
if len(room) == 0 {
|
||||||
|
delete(h.spaceRooms, c.spaceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userRemaining := 0
|
||||||
|
if c.userID != "" && c.userID != "anonymous" {
|
||||||
|
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||||
|
delete(uRoom, c)
|
||||||
|
userRemaining = len(uRoom)
|
||||||
|
if userRemaining == 0 {
|
||||||
|
delete(h.userRooms, c.userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(c.send)
|
||||||
|
|
||||||
|
// If this user has no other active client connections, release their document locks
|
||||||
|
var releasedDocIDs []string
|
||||||
|
if userRemaining == 0 && c.userID != "" {
|
||||||
|
now := time.Now()
|
||||||
|
for docID, l := range h.locks {
|
||||||
|
if l.userID == c.userID || l.expiresAt.Before(now) {
|
||||||
|
releasedDocIDs = append(releasedDocIDs, docID)
|
||||||
|
delete(h.locks, docID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
// Broadcast unlock events for released locks
|
||||||
|
for _, docID := range releasedDocIDs {
|
||||||
|
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document Locking Methods
|
||||||
|
|
||||||
|
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
existing, exists := h.locks[docID]
|
||||||
|
if exists && existing.expiresAt.After(now) {
|
||||||
|
if existing.userID != userID {
|
||||||
|
// Locked by another user
|
||||||
|
info := &domain.DocLockInfo{
|
||||||
|
DocID: existing.docID,
|
||||||
|
UserID: existing.userID,
|
||||||
|
UserName: existing.userName,
|
||||||
|
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return nil, false, info
|
||||||
|
}
|
||||||
|
// Same user extending lock
|
||||||
|
existing.expiresAt = now.Add(ttl)
|
||||||
|
if userName != "" {
|
||||||
|
existing.userName = userName
|
||||||
|
}
|
||||||
|
info := &domain.DocLockInfo{
|
||||||
|
DocID: existing.docID,
|
||||||
|
UserID: existing.userID,
|
||||||
|
UserName: existing.userName,
|
||||||
|
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return info, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fresh lock
|
||||||
|
item := &docLockItem{
|
||||||
|
docID: docID,
|
||||||
|
userID: userID,
|
||||||
|
userName: userName,
|
||||||
|
lockedAt: now,
|
||||||
|
expiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
h.locks[docID] = item
|
||||||
|
info := &domain.DocLockInfo{
|
||||||
|
DocID: item.docID,
|
||||||
|
UserID: item.userID,
|
||||||
|
UserName: item.userName,
|
||||||
|
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
return info, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
item, ok := h.locks[docID]
|
||||||
|
if !ok || item.userID != userID || item.expiresAt.Before(now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
item.expiresAt = now.Add(ttl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) ReleaseDocLock(docID, userID string) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
item, ok := h.locks[docID]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if item.userID == userID || userID == "admin" {
|
||||||
|
delete(h.locks, docID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
item, ok := h.locks[docID]
|
||||||
|
if !ok || item.expiresAt.Before(time.Now()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.DocLockInfo{
|
||||||
|
DocID: item.docID,
|
||||||
|
UserID: item.userID,
|
||||||
|
UserName: item.userName,
|
||||||
|
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) cleanupLocksLoop() {
|
||||||
|
ticker := time.NewTicker(4 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
h.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
var expiredDocIDs []string
|
||||||
|
for docID, l := range h.locks {
|
||||||
|
if l.expiresAt.Before(now) {
|
||||||
|
expiredDocIDs = append(expiredDocIDs, docID)
|
||||||
|
delete(h.locks, docID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, docID := range expiredDocIDs {
|
||||||
|
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcasting Methods
|
||||||
|
|
||||||
|
func (h *Hub) BroadcastToAll(event string, payload interface{}) {
|
||||||
|
msg := domain.WSMessage{
|
||||||
|
Event: event,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: domain.NowISO(),
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
for client := range h.allClients {
|
||||||
|
select {
|
||||||
|
case client.send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
||||||
|
msg := domain.WSMessage{
|
||||||
|
Event: event,
|
||||||
|
Payload: payload,
|
||||||
|
Timestamp: domain.NowISO(),
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
if room, ok := h.userRooms[userID]; ok {
|
||||||
|
for client := range room {
|
||||||
|
select {
|
||||||
|
case client.send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
|
||||||
|
h.BroadcastToUser(userID, event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||||
|
h.BroadcastToAll(event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||||
msg := domain.WSMessage{
|
msg := domain.WSMessage{
|
||||||
Event: event,
|
Event: event,
|
||||||
@@ -90,7 +310,7 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
|||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
room, ok := h.rooms[spaceID]
|
room, ok := h.spaceRooms[spaceID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -115,7 +335,7 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
|
|||||||
conn: conn,
|
conn: conn,
|
||||||
spaceID: spaceID,
|
spaceID: spaceID,
|
||||||
userID: userID,
|
userID: userID,
|
||||||
send: make(chan []byte, 64),
|
send: make(chan []byte, 128),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Register(client)
|
h.Register(client)
|
||||||
@@ -129,7 +349,7 @@ func (c *Client) readPump() {
|
|||||||
_ = c.conn.Close()
|
_ = c.conn.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.conn.SetReadLimit(8192)
|
c.conn.SetReadLimit(16384)
|
||||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
c.conn.SetPongHandler(func(string) error {
|
c.conn.SetPongHandler(func(string) error {
|
||||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
@@ -137,10 +357,42 @@ func (c *Client) readPump() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for {
|
for {
|
||||||
_, _, err := c.conn.ReadMessage()
|
_, message, err := c.conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle client-initiated lock and unlock messages
|
||||||
|
var incoming struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
|
||||||
|
docID := incoming.DocID
|
||||||
|
if docID == "" && len(incoming.Payload) > 0 {
|
||||||
|
var p struct {
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(incoming.Payload, &p)
|
||||||
|
docID = p.DocID
|
||||||
|
}
|
||||||
|
|
||||||
|
if docID != "" {
|
||||||
|
switch incoming.Event {
|
||||||
|
case "LOCK_DOC":
|
||||||
|
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil {
|
||||||
|
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||||||
|
}
|
||||||
|
case "HEARTBEAT_DOC":
|
||||||
|
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
||||||
|
case "UNLOCK_DOC":
|
||||||
|
if c.hub.ReleaseDocLock(docID, c.userID) {
|
||||||
|
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user