From 04fa774d10585ed1d3e0eb0992d5a62d7f356c6f Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 17:08:38 +0100 Subject: [PATCH] refactor(collab): remove live document collaboration and enforce strictly read-only sharing --- frontend/src/components/UnifiedShareModal.tsx | 21 +- .../src/features/workspace/DocumentEditor.tsx | 781 +----------------- .../src/features/workspace/OutlinerBlock.tsx | 152 +--- .../src/features/workspace/OutlinerTree.tsx | 42 +- .../src/features/workspace/WorkspaceView.tsx | 251 ++---- frontend/src/lib/api.ts | 44 - frontend/src/lib/types.ts | 29 - frontend/src/lib/useDocumentSync.ts | 507 ------------ internal/api/server.go | 295 ------- internal/api/server_test.go | 192 +---- internal/domain/models.go | 30 - internal/storage/sqlite.go | 30 +- internal/storage/sqlite_test.go | 31 +- internal/ws/docroom.go | 736 ----------------- internal/ws/docroom_test.go | 85 -- internal/ws/hub.go | 509 +----------- 16 files changed, 179 insertions(+), 3556 deletions(-) delete mode 100644 frontend/src/lib/useDocumentSync.ts delete mode 100644 internal/ws/docroom.go delete mode 100644 internal/ws/docroom_test.go diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx index 77d8dba..009f00e 100644 --- a/frontend/src/components/UnifiedShareModal.tsx +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -46,7 +46,9 @@ export const UnifiedShareModal: React.FC = ({ const [shareType, setShareType] = useState<'team' | 'user'>('team'); const [selectedTeamId, setSelectedTeamId] = useState(''); const [selectedUser, setSelectedUser] = useState(null); - const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor'); + const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>( + allowRoleSelection ? 'editor' : 'viewer' + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -63,7 +65,8 @@ export const UnifiedShareModal: React.FC = ({ setLoading(true); try { - await onAddShare(shareType, targetId, selectedPermission); + const permToUse = allowRoleSelection ? selectedPermission : 'viewer'; + await onAddShare(shareType, targetId, permToUse); setSelectedTeamId(''); setSelectedUser(null); } catch (err: any) { @@ -196,7 +199,7 @@ export const UnifiedShareModal: React.FC = ({ ))} - {allowRoleSelection && ( + {allowRoleSelection ? ( + ) : ( + + + Nur Lesen + )} ) : ( @@ -220,7 +228,7 @@ export const UnifiedShareModal: React.FC = ({ placeholder="Benutzer suchen (Name oder @Benutzername)…" /> - {allowRoleSelection && ( + {allowRoleSelection ? ( + ) : ( + + + Nur Lesen + )} )} diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 8ebb4bc..77d2bd1 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -7,22 +7,18 @@ import { FileText, Loader2, ArrowLeft, - Hash, Lock, Home, ChevronRight, Minimize2, Sparkles, Plus, - AlertTriangle, } from 'lucide-react'; -import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types'; -import { api, getToken, getCurrentUserId, createRealtimeConnection } from '../../lib/api'; -import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync'; +import { Document, DocumentBlock, User } from '../../lib/types'; +import { api, getToken } from '../../lib/api'; import { OutlinerTree } from './OutlinerTree'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; -export type DocLiveState = 'idle' | 'connecting' | 'active' | 'blocked' | 'locked_by_other'; export interface DocumentEditorProps { docId: string; @@ -37,10 +33,7 @@ export interface DocumentEditorProps { onSelectTag: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; onBackMobile?: () => void; - onSyncStatusChange?: (status: DocSyncStatus) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; - onLiveStateChange?: (state: DocLiveState, initiatorName?: string) => void; - registerLiveToggle?: (handler: () => void) => void; } const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒']; @@ -151,7 +144,6 @@ export const DocumentEditor: React.FC = ({ docId, currentUser, initialDoc, - availableFolders, autoFocusTitle, initialFocusedBlockId, onDocMetadataChange, @@ -160,10 +152,7 @@ export const DocumentEditor: React.FC = ({ onSelectTag, onWikiLinkClick, onBackMobile, - onSyncStatusChange, onSaveStatusChange, - onLiveStateChange, - registerLiveToggle, }) => { const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); @@ -175,13 +164,8 @@ export const DocumentEditor: React.FC = ({ const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [zoomedBlockId, setZoomedBlockId] = useState(null); - // Live Collaboration & Locks state - const [liveSession, setLiveSession] = useState(null); - const [isLiveEnabled, setIsLiveEnabled] = useState(false); - const [activeBlockLocks, setActiveBlockLocks] = useState>({}); - - // AutoSave state - const [saveStatus, setSaveStatus] = useState('saved'); + // AutoSave state (REST persistence) + const [, setSaveStatus] = useState('saved'); const [, setSaveErrorMessage] = useState(null); const saveTimeoutRef = useRef(null); const isSavingRef = useRef(false); @@ -189,7 +173,6 @@ export const DocumentEditor: React.FC = ({ const isInitialLoadRef = useRef(true); const titleInputRef = useRef(null); - const titleDebounceRef = useRef(null); const docRef = useRef(doc); docRef.current = doc; @@ -231,157 +214,12 @@ export const DocumentEditor: React.FC = ({ return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId); }, [title, doc?.title, blocks, zoomedBlockId]); - // Reliable current user ID from prop or JWT token - const currentUserId = useMemo(() => { - return currentUser?.id || getCurrentUserId() || ''; - }, [currentUser?.id]); - - // Crucial fix: Collaborator with write permission can edit! + // Strictly Read-Only for shared documents: only the document owner can edit! const canEdit = useMemo(() => { if (!doc) return true; - if (doc.isOwner) return true; - return doc.permission === 'owner' || doc.permission === 'write'; + return Boolean(doc.isOwner || doc.permission === 'owner'); }, [doc]); - // Is this document completely locked because another user has an active live session? - const isLockedByOtherLive = Boolean( - liveSession && - liveSession.initiatorId && - currentUserId && - liveSession.initiatorId !== currentUserId - ); - const effectiveCanEdit = canEdit && !isLockedByOtherLive; - - const isLiveInitiatorOrOwner = Boolean( - (liveSession && currentUserId && liveSession.initiatorId === currentUserId) || - (doc && (doc.isOwner || doc.permission === 'owner')) - ); - - // Handle incoming WebSocket events for THIS document room - const handleRemoteEvent = useCallback( - (evt: DocSyncEvent) => { - if (evt.docId && evt.docId !== docId) return; - - switch (evt.type) { - case 'doc_state': { - if (evt.title !== undefined) { - const isTitleActivelyEditing = - titleInputRef.current !== null && - document.activeElement === titleInputRef.current && - typeof document !== 'undefined' && - document.hasFocus(); - if (!isTitleActivelyEditing) { - setTitle(evt.title); - } - } - if (Array.isArray(evt.blocks) && evt.blocks.length > 0) { - setBlocks(ensureNonEmptyBlocks(docId, evt.blocks)); - } - break; - } - - case 'title_updated': { - if (evt.title !== undefined) { - const isTitleActivelyEditing = - titleInputRef.current !== null && - document.activeElement === titleInputRef.current && - typeof document !== 'undefined' && - document.hasFocus(); - if (!isTitleActivelyEditing) { - setTitle(evt.title); - } - if (docRef.current) { - const updated = { ...docRef.current, title: evt.title }; - setDoc(updated); - onDocMetadataChange(updated); - } - } - break; - } - - case 'block_created': { - if (!evt.block) return; - const incoming = evt.block; - setBlocks((prev) => { - if (prev.some((b) => b.id === incoming.id)) { - return prev.map((b) => (b.id === incoming.id ? incoming : b)); - } - const next = [...prev]; - if (evt.afterBlockId) { - const idx = next.findIndex((b) => b.id === evt.afterBlockId); - if (idx >= 0) { - next.splice(idx + 1, 0, incoming); - return ensureNonEmptyBlocks(docId, next); - } - } - next.push(incoming); - return ensureNonEmptyBlocks(docId, next); - }); - break; - } - - case 'block_updated': { - const targetId = evt.blockId || evt.block?.id; - if (!targetId) return; - setBlocks((prev) => - prev.map((b) => { - if (b.id !== targetId) return b; - if (evt.block) { - return { ...b, ...evt.block }; - } - return { - ...b, - content: evt.content !== undefined ? evt.content : b.content, - level: evt.level !== undefined ? evt.level : b.level, - checked: evt.checked !== undefined ? evt.checked : b.checked, - collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed, - }; - }) - ); - break; - } - - case 'block_deleted': { - if (!evt.blockId) return; - setBlocks((prev) => - ensureNonEmptyBlocks( - docId, - prev.filter((b) => b.id !== evt.blockId) - ) - ); - break; - } - - case 'block_moved': { - if (!evt.blockId || evt.newIndex === undefined) return; - const targetIndex = evt.newIndex; - setBlocks((prev) => { - const fromIdx = prev.findIndex((b) => b.id === evt.blockId); - if (fromIdx === -1) return prev; - const copy = [...prev]; - const [moving] = copy.splice(fromIdx, 1); - const updatedMoving: DocumentBlock = { - ...moving, - level: evt.level !== undefined ? evt.level : moving.level, - }; - const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length)); - copy.splice(boundedIdx, 0, updatedMoving); - return ensureNonEmptyBlocks(docId, copy); - }); - break; - } - - case 'blocks_replaced': { - if (Array.isArray(evt.blocks)) { - setBlocks(ensureNonEmptyBlocks(docId, evt.blocks)); - } - break; - } - } - }, - [docId, onDocMetadataChange] - ); - // Extract all #tags present across the current blocks const extractedTags = useMemo(() => { const tagSet = new Set(); @@ -399,7 +237,7 @@ export const DocumentEditor: React.FC = ({ const currentTagsRef = useRef(extractedTags); currentTagsRef.current = extractedTags; - // AutoSave implementation (authoritative persistence via HTTP REST) + // AutoSave snapshot const currentSnapshot = useMemo(() => { return JSON.stringify({ title, @@ -413,7 +251,7 @@ export const DocumentEditor: React.FC = ({ const executeAutoSave = useCallback( async (immediate: boolean = false) => { - if (!docId || !canEdit || isLockedByOtherLive) return; + if (!docId || !canEdit) return; if (isSavingRef.current && !immediate) return; const snapshot = currentSnapshotRef.current; @@ -451,11 +289,12 @@ export const DocumentEditor: React.FC = ({ onSaveStatusChange?.('error', msg); } }, - [docId, canEdit, isLockedByOtherLive, onSaveStatusChange, onDocMetadataChange] + [docId, canEdit, onSaveStatusChange, onDocMetadataChange] ); useEffect(() => { if (isInitialLoadRef.current) return; + if (!canEdit) return; if (currentSnapshot === lastSavedSnapshotRef.current) return; setSaveStatus('unsaved'); @@ -475,7 +314,7 @@ export const DocumentEditor: React.FC = ({ saveTimeoutRef.current = null; } }; - }, [currentSnapshot, executeAutoSave, onSaveStatusChange]); + }, [currentSnapshot, canEdit, executeAutoSave, onSaveStatusChange]); // Flush on page unload / pagehide via keepalive fetch useEffect(() => { @@ -485,7 +324,7 @@ export const DocumentEditor: React.FC = ({ saveTimeoutRef.current = null; } const snapshot = currentSnapshotRef.current; - if (snapshot !== lastSavedSnapshotRef.current && canEdit && !isLockedByOtherLive) { + if (snapshot !== lastSavedSnapshotRef.current && canEdit) { const token = getToken(); if (token) { fetch(`/api/v1/documents/${docId}`, { @@ -512,388 +351,9 @@ export const DocumentEditor: React.FC = ({ window.removeEventListener('pagehide', handleUnload); handleUnload(); }; - }, [docId, canEdit, isLockedByOtherLive]); + }, [docId, canEdit]); - const { - status: syncStatus, - remoteCursors, - sendCursorMoved, - sendBlockCreated, - sendBlockUpdated, - sendBlockDeleted, - sendBlockMoved, - sendTitleUpdated, - } = useDocumentSync(docId, handleRemoteEvent, effectiveCanEdit, currentUser, isLiveEnabled); - - useEffect(() => { - onSyncStatusChange?.(syncStatus); - }, [syncStatus, onSyncStatusChange]); - - const computedLiveState: DocLiveState = useMemo(() => { - if (isLockedByOtherLive) return 'locked_by_other'; - if (!isLiveEnabled) return 'idle'; - if (syncStatus === 'connecting' || syncStatus === 'reconnecting') return 'connecting'; - if (syncStatus === 'blocked') return 'blocked'; - if (syncStatus === 'connected') return 'active'; - return 'idle'; - }, [isLockedByOtherLive, isLiveEnabled, syncStatus]); - - useEffect(() => { - onLiveStateChange?.(computedLiveState, liveSession?.initiatorName); - }, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]); - - const isLiveEnabledRef = useRef(isLiveEnabled); - isLiveEnabledRef.current = isLiveEnabled; - - const handleToggleLive = useCallback(async () => { - if (isLiveEnabled || isLiveInitiatorOrOwner) { - try { - await api.endLiveSession(docId); - } catch {} - setIsLiveEnabled(false); - setLiveSession(null); - } else { - if (isLockedByOtherLive) return; - try { - const session = await api.startLiveSession(docId); - setLiveSession(session); - setIsLiveEnabled(true); - } catch (err) { - console.error('[DocumentEditor] Failed to start live session:', err); - } - } - }, [docId, isLiveEnabled, isLiveInitiatorOrOwner, isLockedByOtherLive]); - - // Keep live session lease alive while isLiveEnabled - useEffect(() => { - if (!isLiveEnabled || !docId) return; - const interval = window.setInterval(() => { - api.startLiveSession(docId).catch(() => {}); - }, 20000); - return () => window.clearInterval(interval); - }, [isLiveEnabled, docId]); - - // Cleanup live session on page unload - useEffect(() => { - const handleUnloadLive = () => { - if (isLiveEnabledRef.current) { - const token = getToken(); - if (token) { - fetch(`/api/v1/documents/${docId}/live/end`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - keepalive: true, - }).catch(() => {}); - } - } - }; - window.addEventListener('beforeunload', handleUnloadLive); - window.addEventListener('pagehide', handleUnloadLive); - return () => { - window.removeEventListener('beforeunload', handleUnloadLive); - window.removeEventListener('pagehide', handleUnloadLive); - }; - }, [docId]); - - useEffect(() => { - if (registerLiveToggle) { - registerLiveToggle(handleToggleLive); - } - }, [registerLiveToggle, handleToggleLive]); - - // Handle remote document updates (from other users saving via HTTP or realtime) - const handleRemoteDocUpdated = useCallback( - (remoteDoc: Document) => { - if (!remoteDoc || remoteDoc.id !== docId || !Array.isArray(remoteDoc.blocks)) return; - - const isTitleActivelyEditing = - titleInputRef.current !== null && - document.activeElement === titleInputRef.current && - typeof document !== 'undefined' && - document.hasFocus(); - - let targetTitle = currentTitleRef.current; - if (!isTitleActivelyEditing && remoteDoc.title !== undefined) { - setTitle(remoteDoc.title); - currentTitleRef.current = remoteDoc.title; - targetTitle = remoteDoc.title; - } - - // Merge remote blocks: preserve any block currently being actively typed by local user, - // update all other blocks from the authoritative server state. - const localFocusedId = focusedBlockIdRef.current; - const localBlocks = currentBlocksRef.current; - const localFocusedBlock = localFocusedId ? localBlocks.find((b) => b.id === localFocusedId) : null; - - const mergedBlocks = remoteDoc.blocks.map((remoteB) => { - if (localFocusedId && remoteB.id === localFocusedId && localFocusedBlock) { - return { ...remoteB, content: localFocusedBlock.content }; - } - return remoteB; - }); - - // If local newly-created block isn't on server yet, retain it - if (localFocusedId && localFocusedBlock && !mergedBlocks.some((b) => b.id === localFocusedId)) { - mergedBlocks.push(localFocusedBlock); - } - - const nonEmpties = ensureNonEmptyBlocks(docId, mergedBlocks); - setBlocks(nonEmpties); - currentBlocksRef.current = nonEmpties; - - const newSnapshot = JSON.stringify({ - title: targetTitle, - blocks: nonEmpties, - tags: remoteDoc.tags || currentTagsRef.current || [], - }); - lastSavedSnapshotRef.current = newSnapshot; - currentSnapshotRef.current = newSnapshot; - if (remoteDoc.updatedAt) { - lastKnownUpdatedAtRef.current = remoteDoc.updatedAt; - } - - if (docRef.current) { - docRef.current = { ...docRef.current, ...remoteDoc }; - } - onDocMetadataChange(remoteDoc); - }, - [docId, onDocMetadataChange] - ); - - const handleRemoteBlocksPatched = useCallback( - (patch: { upserted?: DocumentBlock[]; deletedIDs?: string[]; blockOrder?: string[] }) => { - console.log('[DocumentEditor] DOC_BLOCKS_PATCHED empfangen:', patch); - const localFocusedId = focusedBlockIdRef.current; - const localBlocks = currentBlocksRef.current; - const localFocusedBlock = localFocusedId ? localBlocks.find((b) => b.id === localFocusedId) : null; - - const blockMap = new Map(); - for (const b of localBlocks) { - blockMap.set(b.id, b); - } - - if (Array.isArray(patch.upserted)) { - for (const u of patch.upserted) { - if (localFocusedId && u.id === localFocusedId && localFocusedBlock) { - blockMap.set(u.id, { ...u, content: localFocusedBlock.content }); - } else { - blockMap.set(u.id, u); - } - } - } - - if (Array.isArray(patch.deletedIDs)) { - for (const delId of patch.deletedIDs) { - if (delId !== localFocusedId) { - blockMap.delete(delId); - } - } - } - - let reconciledBlocks: DocumentBlock[] = []; - if (Array.isArray(patch.blockOrder) && patch.blockOrder.length > 0) { - for (const id of patch.blockOrder) { - const b = blockMap.get(id); - if (b) reconciledBlocks.push(b); - } - blockMap.forEach((b, id) => { - if (!patch.blockOrder!.includes(id)) { - reconciledBlocks.push(b); - } - }); - } else { - reconciledBlocks = Array.from(blockMap.values()); - } - - const nonEmpties = ensureNonEmptyBlocks(docId, reconciledBlocks); - setBlocks(nonEmpties); - currentBlocksRef.current = nonEmpties; - - const newSnapshot = JSON.stringify({ - title: currentTitleRef.current, - blocks: nonEmpties, - tags: currentTagsRef.current, - }); - lastSavedSnapshotRef.current = newSnapshot; - currentSnapshotRef.current = newSnapshot; - }, - [docId] - ); - - // Sync when initialDoc prop changes externally - useEffect(() => { - if (initialDoc && initialDoc.id === docId && initialDoc.updatedAt && initialDoc.updatedAt !== lastKnownUpdatedAtRef.current) { - lastKnownUpdatedAtRef.current = initialDoc.updatedAt; - handleRemoteDocUpdated(initialDoc); - } - }, [initialDoc, docId, handleRemoteDocUpdated]); - - // Periodic poll for locks & document text (guarantees text synchronization even without WebSockets) - useEffect(() => { - if (!docId) return; - let isCancelled = false; - - const syncLocksAndDoc = async () => { - try { - // 1. Sync locks - const locksRes = await api.getDocLocks(docId); - if (isCancelled) return; - if (Array.isArray(locksRes.blockLocks)) { - const map: Record = {}; - for (const l of locksRes.blockLocks) { - map[l.blockId] = l; - } - setActiveBlockLocks(map); - } - if (locksRes.liveSession) { - setLiveSession(locksRes.liveSession); - } else { - setLiveSession(null); - } - - // 2. Sync document content if not currently actively saving - if (!isSavingRef.current) { - const remoteDoc = await api.getDocument(docId); - if (isCancelled || !remoteDoc) return; - - // Check if server has newer or different state - if (remoteDoc.updatedAt && remoteDoc.updatedAt !== lastKnownUpdatedAtRef.current) { - console.log(`[DocumentEditor] Polling: Neuer Text-Stand vom Server (${remoteDoc.updatedAt}). Aktualisiere nicht-fokussierte Blöcke...`); - lastKnownUpdatedAtRef.current = remoteDoc.updatedAt; - handleRemoteDocUpdated(remoteDoc); - } - } - } catch (err) { - // ignore poll errors - } - }; - - syncLocksAndDoc(); - const interval = window.setInterval(syncLocksAndDoc, 2000); - return () => { - isCancelled = true; - window.clearInterval(interval); - }; - }, [docId, handleRemoteDocUpdated]); - - // Subscribe to realtime lock & live session events - useEffect(() => { - const conn = createRealtimeConnection({ - docId, - onMessage: (msg: WSMessage) => { - if (msg.event === 'DOC_BLOCK_LOCKED') { - const p = (msg.payload?.lock || msg.payload) as BlockLockInfo; - console.log('[DocumentEditor] DOC_BLOCK_LOCKED empfangen:', p); - if (p?.docId === docId && p?.blockId) { - setActiveBlockLocks((prev) => ({ - ...prev, - [p.blockId]: p, - })); - } - } else if (msg.event === 'DOC_BLOCK_UNLOCKED') { - const p = msg.payload; - console.log('[DocumentEditor] DOC_BLOCK_UNLOCKED empfangen:', p); - if (p?.docId === docId && p?.blockId) { - setActiveBlockLocks((prev) => { - const next = { ...prev }; - delete next[p.blockId]; - return next; - }); - } - } else if (msg.event === 'DOC_UPDATED') { - const p = msg.payload as Document; - console.log('[DocumentEditor] DOC_UPDATED von anderem Benutzer empfangen:', p); - if (p?.id === docId && Array.isArray(p?.blocks)) { - handleRemoteDocUpdated(p); - } - } else if (msg.event === 'DOC_BLOCKS_PATCHED') { - const p = msg.payload; - console.log('[DocumentEditor] DOC_BLOCKS_PATCHED von Server empfangen:', p); - if (p?.docId === docId) { - handleRemoteBlocksPatched(p); - } - } else if (msg.event === 'DOC_LIVE_STARTED') { - const s = (msg.payload?.session || msg.payload) as LiveSessionInfo; - console.log('[DocumentEditor] DOC_LIVE_STARTED empfangen:', s); - if (s?.docId === docId) { - setLiveSession(s); - if (currentUserId && s.initiatorId === currentUserId) { - setIsLiveEnabled(true); - } - } - } else if (msg.event === 'DOC_LIVE_ENDED') { - console.log('[DocumentEditor] DOC_LIVE_ENDED empfangen'); - if (msg.payload?.docId === docId) { - setLiveSession(null); - setIsLiveEnabled(false); - } - } - }, - }); - - return () => { - conn.close(); - }; - }, [docId, currentUserId, handleRemoteDocUpdated, handleRemoteBlocksPatched]); - - // Block focus/blur locks - const localUserColor = useMemo( - () => getUserColor(currentUser?.id || 'anon'), - [currentUser?.id] - ); - const focusedBlockIdRef = useRef(null); - const lockKeepaliveTimerRef = useRef(null); - - const handleFocusBlock = useCallback( - (blockId: string) => { - if (!effectiveCanEdit) return; - focusedBlockIdRef.current = blockId; - console.log(`[DocumentEditor] Fokus auf Block "${blockId}". Sende Lock an Server...`); - api.lockBlock(docId, blockId, localUserColor, 30).catch((err) => { - console.warn(`[DocumentEditor] Lock für Block "${blockId}" fehlgeschlagen:`, err); - }); - - if (lockKeepaliveTimerRef.current !== null) { - window.clearInterval(lockKeepaliveTimerRef.current); - } - lockKeepaliveTimerRef.current = window.setInterval(() => { - if (focusedBlockIdRef.current === blockId) { - api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {}); - } - }, 15000); - }, - [docId, effectiveCanEdit, localUserColor] - ); - - const handleBlurBlock = useCallback( - (blockId: string) => { - console.log(`[DocumentEditor] Blur von Block "${blockId}". Gebe Lock frei und sichere per AutoSave...`); - if (lockKeepaliveTimerRef.current !== null) { - window.clearInterval(lockKeepaliveTimerRef.current); - lockKeepaliveTimerRef.current = null; - } - if (focusedBlockIdRef.current === blockId) { - focusedBlockIdRef.current = null; - } - api.unlockBlock(docId, blockId).catch(() => {}); - executeAutoSave(true); - }, - [docId, executeAutoSave] - ); - - useEffect(() => { - return () => { - if (lockKeepaliveTimerRef.current !== null) { - window.clearInterval(lockKeepaliveTimerRef.current); - lockKeepaliveTimerRef.current = null; - } - if (focusedBlockIdRef.current) { - api.unlockBlock(docId, focusedBlockIdRef.current).catch(() => {}); - } - }; - }, [docId]); - - // Fetch isolated document state on mount + // Fetch document on mount useEffect(() => { let isCancelled = false; if (!initialDoc) { @@ -939,10 +399,6 @@ export const DocumentEditor: React.FC = ({ return () => { isCancelled = true; - if (titleDebounceRef.current !== null) { - window.clearTimeout(titleDebounceRef.current); - titleDebounceRef.current = null; - } }; }, [docId]); @@ -960,78 +416,28 @@ export const DocumentEditor: React.FC = ({ onDocMetadataChange(updatedDoc); }, [blocks, extractedTags, title]); - // Active remote users in this room (excluding self) - const activeUsers = useMemo(() => { - const list: RemoteCursor[] = []; - const seen = new Set(); - for (const c of Object.values(remoteCursors)) { - const key = c.userId || c.clientId; - if (!seen.has(key)) { - seen.add(key); - list.push(c); - } - } - return list; - }, [remoteCursors]); - const handleTitleChange = (e: React.ChangeEvent) => { - if (!effectiveCanEdit) return; - const nextTitle = e.target.value; - setTitle(nextTitle); - - if (titleDebounceRef.current !== null) { - window.clearTimeout(titleDebounceRef.current); - } - titleDebounceRef.current = window.setTimeout(() => { - titleDebounceRef.current = null; - if (isLiveEnabled) { - sendTitleUpdated(nextTitle); - } - }, 250); + if (!canEdit) return; + setTitle(e.target.value); }; const handleTitleBlur = () => { - if (titleDebounceRef.current !== null) { - window.clearTimeout(titleDebounceRef.current); - titleDebounceRef.current = null; - if (isLiveEnabled) { - sendTitleUpdated(title); - } - } executeAutoSave(); }; - // Flush title debouncing on window blur so background window never holds stale pending title - useEffect(() => { - const handleWindowBlur = () => { - if (titleDebounceRef.current !== null) { - window.clearTimeout(titleDebounceRef.current); - titleDebounceRef.current = null; - if (isLiveEnabled) { - sendTitleUpdated(title); - } - } - }; - window.addEventListener('blur', handleWindowBlur); - return () => window.removeEventListener('blur', handleWindowBlur); - }, [title, isLiveEnabled, sendTitleUpdated]); - const handleUpdateBlock = useCallback( (updatedBlock: DocumentBlock) => { - if (!effectiveCanEdit) return; + if (!canEdit) return; setBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)) ); - if (isLiveEnabled) { - sendBlockUpdated(updatedBlock); - } }, - [effectiveCanEdit, isLiveEnabled, sendBlockUpdated] + [canEdit] ); const handleUpdateBlocks = useCallback( (updatedBlocks: DocumentBlock[]) => { - if (!effectiveCanEdit || updatedBlocks.length === 0) return; + if (!canEdit || updatedBlocks.length === 0) return; const map = new Map(); for (const b of updatedBlocks) { map.set(b.id, b); @@ -1039,60 +445,43 @@ export const DocumentEditor: React.FC = ({ setBlocks((prev) => prev.map((b) => map.get(b.id) || b) ); - if (isLiveEnabled) { - for (const b of updatedBlocks) { - sendBlockUpdated(b); - } - } }, - [effectiveCanEdit, isLiveEnabled, sendBlockUpdated] + [canEdit] ); const handleCreateBlock = useCallback( (newBlock: DocumentBlock, afterBlockId?: string) => { - if (!effectiveCanEdit) return; + if (!canEdit) return; setBlocks((prev) => { const next = [...prev]; if (afterBlockId) { const idx = next.findIndex((b) => b.id === afterBlockId); if (idx >= 0) { next.splice(idx + 1, 0, newBlock); - const normalized = ensureNonEmptyBlocks(docId, next); - if (isLiveEnabled) { - sendBlockCreated(newBlock, afterBlockId, normalized); - } - return normalized; + return ensureNonEmptyBlocks(docId, next); } } next.push(newBlock); - const normalized = ensureNonEmptyBlocks(docId, next); - if (isLiveEnabled) { - sendBlockCreated(newBlock, afterBlockId, normalized); - } - return normalized; + return ensureNonEmptyBlocks(docId, next); }); }, - [docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated] + [docId, canEdit] ); const handleDeleteBlock = useCallback( (blockId: string) => { - if (!effectiveCanEdit) return; + if (!canEdit) return; setBlocks((prev) => { const filtered = prev.filter((b) => b.id !== blockId); - const normalized = ensureNonEmptyBlocks(docId, filtered); - if (isLiveEnabled) { - sendBlockDeleted(blockId, normalized); - } - return normalized; + return ensureNonEmptyBlocks(docId, filtered); }); }, - [docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted] + [docId, canEdit] ); const handleMoveBlock = useCallback( (blockId: string, targetIndex: number, newLevel?: number) => { - if (!effectiveCanEdit) return; + if (!canEdit) return; setBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === blockId); if (fromIdx === -1) return prev; @@ -1141,15 +530,10 @@ export const DocumentEditor: React.FC = ({ })); copy.splice(insertIndex, 0, ...updatedSubtree); - const normalized = ensureNonEmptyBlocks(docId, copy); - - if (isLiveEnabled) { - sendBlockMoved(blockId, insertIndex, updatedSubtree[0].level, normalized); - } - return normalized; + return ensureNonEmptyBlocks(docId, copy); }); }, - [docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved] + [docId, canEdit] ); const handleTogglePin = async () => { @@ -1165,7 +549,7 @@ export const DocumentEditor: React.FC = ({ const handleSelectIcon = async (icon: string) => { setShowEmojiPicker(false); - if (!doc || !effectiveCanEdit) return; + if (!doc || !canEdit) return; try { const updated = await api.updateDocument(doc.id, { icon }); setDoc(updated); @@ -1308,37 +692,9 @@ export const DocumentEditor: React.FC = ({ )} - {/* Right Status & Action Buttons */} + {/* Right Action Buttons */}
- {/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */} - {activeUsers.length > 0 && ( -
- {activeUsers.map((u) => { - const initials = (u.userName || 'A') - .split(' ') - .map((n) => n[0]) - .slice(0, 2) - .join('') - .toUpperCase(); - return ( -
-
- {initials} -
- {/* Tooltip */} -
- {u.userName} -
-
- ); - })} -
- )} - - {/* Sharing Section (1:1 styling from SharedListsView) */} + {/* Sharing Section */} {doc && (() => { const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); const sharesCount = doc.sharedWith?.length || 0; @@ -1418,44 +774,11 @@ export const DocumentEditor: React.FC = ({
- {/* Live Session Active Banner (when another collaborator is live) */} - {isLockedByOtherLive && ( -
-
- - - Live-Sitzung aktiv (Bearbeitet von {liveSession?.initiatorName || 'anderer Nutzer'}) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden. - -
- {isLiveInitiatorOrOwner && ( - - )} -
- )} - - {/* WebSocket Blocked Banner (when current user has live enabled but WS fails in corporate network) */} - {isLiveEnabled && syncStatus === 'blocked' && ( -
-
- - - Live-Zusammenarbeit nicht möglich (WebSockets im Firmennetzwerk blockiert). HTTPS Auto-Save ist aktiv. - -
- + {/* Read-only notification banner */} + {!canEdit && ( +
+ + Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).
)} @@ -1466,7 +789,6 @@ export const DocumentEditor: React.FC = ({ FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) ========================================================================= */
- {/* Prominent Zoomed Ast-Heading */}
@@ -1499,7 +821,7 @@ export const DocumentEditor: React.FC = ({ } }} placeholder="Ast-Titel…" - readOnly={!effectiveCanEdit} + readOnly={!canEdit} className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight" />
@@ -1515,11 +837,10 @@ export const DocumentEditor: React.FC = ({
- {/* Subtree Children or Empty State */} {childBlocks.length === 0 ? (

Dieser Ast hat noch keine Unterpunkte.

- {effectiveCanEdit && ( + {canEdit && (
@@ -1574,13 +889,13 @@ export const DocumentEditor: React.FC = ({
- {showEmojiPicker && effectiveCanEdit && ( + {showEmojiPicker && canEdit && (
{EMOJI_PALETTE.map((emoji) => (
) : block.type === 'callout' ? ( @@ -630,17 +547,12 @@ export const OutlinerBlock: React.FC = ({ }} rows={1} value={localContent} - readOnly={!canEdit || isLockedByOther} + readOnly={!canEdit} onFocus={handleFocus} onBlur={handleBlur} - onClick={handleClick} - onKeyUp={handleKeyUp} - onSelect={handleSelect} onChange={handleContentChange} onKeyDown={handleKeyDown} - className={`w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic ${ - isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : '' - }`} + className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic" />
) : ( @@ -652,12 +564,9 @@ export const OutlinerBlock: React.FC = ({ }} rows={1} value={localContent} - readOnly={!canEdit || isLockedByOther} + readOnly={!canEdit} onFocus={handleFocus} onBlur={handleBlur} - onClick={handleClick} - onKeyUp={handleKeyUp} - onSelect={handleSelect} onChange={handleContentChange} onKeyDown={handleKeyDown} placeholder={ @@ -666,8 +575,6 @@ export const OutlinerBlock: React.FC = ({ : '' } className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${ - isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : '' - } ${ block.type === 'h1' ? 'text-lg font-bold text-main tracking-tight' : block.type === 'h2' @@ -679,45 +586,6 @@ export const OutlinerBlock: React.FC = ({ : 'text-sm text-main' }`} /> - - {/* Remote Caret & Floating Name Tag Overlay */} - {primaryRemoteCursor && - primaryRemoteCursor.selectionStart !== undefined && - primaryRemoteCursor.selectionStart !== null && ( - - )}
)} diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index c8657d1..4686924 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -1,7 +1,6 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { Plus } from 'lucide-react'; -import { DocumentBlock, BlockLockInfo } from '../../lib/types'; -import { RemoteCursor } from '../../lib/useDocumentSync'; +import { DocumentBlock } from '../../lib/types'; import { OutlinerBlock } from './OutlinerBlock'; export interface OutlinerTreeProps { @@ -10,8 +9,6 @@ export interface OutlinerTreeProps { canEdit: boolean; levelOffset?: number; onZoomBlock?: (blockId: string) => void; - remoteCursors?: Record; - sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onUpdateBlocks?: (updated: DocumentBlock[]) => void; onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void; @@ -20,10 +17,6 @@ export interface OutlinerTreeProps { onTagClick?: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; initialFocusedBlockId?: string | null; - activeBlockLocks?: Record; - onFocusBlock?: (blockId: string) => void; - onBlurBlock?: (blockId: string) => void; - currentUserId?: string; } export const OutlinerTree: React.FC = ({ @@ -32,8 +25,6 @@ export const OutlinerTree: React.FC = ({ canEdit, levelOffset = 0, onZoomBlock, - remoteCursors, - sendCursorMoved, onUpdateBlock, onUpdateBlocks, onCreateBlock, @@ -42,10 +33,6 @@ export const OutlinerTree: React.FC = ({ onTagClick, onWikiLinkClick, initialFocusedBlockId, - activeBlockLocks, - onFocusBlock, - onBlurBlock, - currentUserId, }) => { const [focusedBlockId, setFocusedBlockId] = useState(null); const [highlightedBlockId, setHighlightedBlockId] = useState( @@ -371,24 +358,9 @@ export const OutlinerTree: React.FC = ({ return () => window.removeEventListener('dragend', clearDrag); }, []); - const cursorsByBlock = useMemo(() => { - const map: Record = {}; - if (!remoteCursors) return map; - for (const c of Object.values(remoteCursors)) { - if (c.blockId) { - if (!map[c.blockId]) map[c.blockId] = []; - map[c.blockId].push(c); - } - } - return map; - }, [remoteCursors]); - return (
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => { - const lockInfo = activeBlockLocks?.[block.id]; - const isLockedByOther = Boolean(lockInfo && currentUserId && lockInfo.userId !== currentUserId); - return ( = ({ canEdit={canEdit} levelOffset={levelOffset} onZoomBlock={onZoomBlock} - activeRemoteCursors={cursorsByBlock[block.id] || []} - sendCursorMoved={sendCursorMoved} - onFocusBlock={(bId) => { - setFocusedBlockId(bId); - onFocusBlock?.(bId); - }} - onBlurBlock={(bId) => { - onBlurBlock?.(bId); - }} onUpdateBlock={onUpdateBlock} onCreateBlockBelow={handleCreateBlockBelow} onDeleteBlock={handleDeleteBlock} @@ -422,9 +385,6 @@ export const OutlinerTree: React.FC = ({ isDragTarget={dragOverIndex === index && draggedIndex !== index} isTargeted={highlightedBlockId === block.id} registerTextareaRef={registerTextareaRef} - isLockedByOther={isLockedByOther} - lockedByName={lockInfo?.userName} - lockedByColor={lockInfo?.userColor} /> ); })} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 7a0b127..4289465 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -48,10 +48,9 @@ import { getRealtimeTransport, RealtimeTransport, } from '../../lib/api'; -import { DocSyncStatus } from '../../lib/useDocumentSync'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { useMobileActions } from '../../context/MobileActionContext'; -import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor'; +import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; interface WorkspaceViewProps { currentUser?: User | null; @@ -328,13 +327,8 @@ export const WorkspaceView: React.FC = ({ // Realtime connection status for top header indicator (identical to SharedListsView) const [realtimeConnected, setRealtimeConnected] = useState(() => isRealtimeConnected()); const [realtimeTransport, setRealtimeTransport] = useState(() => getRealtimeTransport()); - const [activeDocSyncStatus, setActiveDocSyncStatus] = useState('connected'); const [docSaveStatus, setDocSaveStatus] = useState('saved'); const [docSaveError, setDocSaveError] = useState(null); - const [docLiveState, setDocLiveState] = useState('idle'); - const [docLiveInitiator, setDocLiveInitiator] = useState(null); - const [showWsBlockedModal, setShowWsBlockedModal] = useState(false); - const liveToggleRef = useRef<(() => void) | null>(null); useEffect(() => { const unsub = onRealtimeStatus((connected, transport) => { @@ -1168,136 +1162,52 @@ export const WorkspaceView: React.FC = ({ {/* Right Header Section: Save Status & Live Collaboration Button */}
{subView === 'editor' && selectedDocId ? ( - <> - {/* Save Status Pill */} -
- {docSaveStatus === 'saved' && ( - <> - - Gespeichert - - )} - {docSaveStatus === 'saving' && ( - <> - - Speichert… - - )} - {docSaveStatus === 'unsaved' && ( - <> - - Ungespeichert - - )} - {docSaveStatus === 'error' && ( - <> - - Fehler - - )} -
- - {/* Interactive Live Collaboration Button (replaces passive radio icon) */} - {docLiveState === 'locked_by_other' ? ( -
-
- - Live: {docLiveInitiator || 'Gesperrt'} -
- {(selectedDocObj?.isOwner || selectedDocObj?.permission === 'owner') && ( - - )} -
- ) : docLiveState === 'blocked' ? ( -
- - -
- ) : docLiveState === 'connecting' ? ( -
- - -
- ) : docLiveState === 'active' ? ( - - ) : ( - + /* Save Status Pill */ +
+ {docSaveStatus === 'saved' && ( + <> + + Gespeichert + )} - + {docSaveStatus === 'saving' && ( + <> + + Speichert… + + )} + {docSaveStatus === 'unsaved' && ( + <> + + Ungespeichert + + )} + {docSaveStatus === 'error' && ( + <> + + Fehler + + )} +
) : ( /* Standalone Realtime Status Indicator for Todos / Hashtags */ (() => { @@ -1568,18 +1478,10 @@ export const WorkspaceView: React.FC = ({ onSelectTag={handleSelectTag} onWikiLinkClick={handleWikiLinkClick} onBackMobile={() => setMobileShowDetail(false)} - onSyncStatusChange={setActiveDocSyncStatus} onSaveStatusChange={(status, err) => { setDocSaveStatus(status); setDocSaveError(err || null); }} - onLiveStateChange={(state, initiator) => { - setDocLiveState(state); - setDocLiveInitiator(initiator || null); - }} - registerLiveToggle={(handler) => { - liveToggleRef.current = handler; - }} /> ) : subView === 'tags' ? ( /* ========================================================================= @@ -2535,14 +2437,15 @@ export const WorkspaceView: React.FC = ({ shares={unifiedShares} teams={teams} collaborators={collaborators} + allowRoleSelection={!shareModalDoc} onAddShare={async (targetType, targetId, permission) => { - const perm = permission === 'editor' ? 'write' : 'read'; + const perm = (!shareModalDoc && permission === 'editor') ? 'write' : 'read'; if (shareModalDoc) { await api.createDocShare( shareModalDoc.id, targetType === 'team' - ? { teamId: targetId, permission: perm } - : { userId: targetId, permission: perm } + ? { teamId: targetId, permission: 'read' } + : { userId: targetId, permission: 'read' } ); const updatedShares = await api.getDocShares(shareModalDoc.id); setActiveDocShares(updatedShares); @@ -2569,60 +2472,6 @@ export const WorkspaceView: React.FC = ({ }} /> )} - - {/* WebSocket Blocked Modal */} - {showWsBlockedModal && ( -
-
-
-
- -
-
-

- WebSockets in diesem Netzwerk blockiert -

-

- Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (ws:// / wss://). -

-
-
- -
-
- - Kein Datenverlust: Auto-Save per HTTPS ist aktiv! -
-

- Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert. -

-

- Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt. -

-
- -
- - -
-
-
- )}
); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1aca9f9..a70d32a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,10 +5,6 @@ import { Document, DocumentBlock, DocShare, - DocLockInfo, - BlockLockInfo, - LiveSessionInfo, - DocLocksResponse, KanbanColumn, ListItem, ListShare, @@ -641,46 +637,6 @@ export const api = { await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); }, - async getDocLocks(docId: string): Promise { - const res = await request(`/api/v1/documents/${docId}/locks`); - return { - liveSession: res.liveSession || null, - blockLocks: ensureArray(res.blockLocks), - }; - }, - - async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise { - console.log(`[MindSift Lock] Lock anfordern für Block "${blockId}" in Notiz "${docId}"...`); - const res = await request(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, { - method: 'POST', - body: JSON.stringify({ userColor, ttlSeconds, ttl: ttlSeconds }), - }); - console.log(`[MindSift Lock] Lock erfolgreich erhalten für "${blockId}":`, res?.lock || res); - return res?.lock || res; - }, - - async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> { - console.log(`[MindSift Lock] Lock freigeben für Block "${blockId}" in Notiz "${docId}"...`); - const res = await request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, { - method: 'POST', - }); - console.log(`[MindSift Lock] Lock freigegeben für "${blockId}"`); - return res; - }, - - async startLiveSession(docId: string): Promise { - const res = await request(`/api/v1/documents/${docId}/live/start`, { - method: 'POST', - }); - return res?.session || res; - }, - - async endLiveSession(docId: string): Promise<{ status: string }> { - return request<{ status: string }>(`/api/v1/documents/${docId}/live/end`, { - method: 'POST', - }); - }, - async moveDocument(id: string, folderPath: string): Promise { const d = await request(`/api/v1/documents/${id}/move`, { method: 'PUT', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 7a5e479..4ec9d6b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -200,35 +200,6 @@ export interface DocShare { createdAt: string; } -export interface DocLockInfo { - docId: string; - userId: string; - userName: string; - expiresAt: string; -} - -export interface BlockLockInfo { - docId: string; - blockId: string; - userId: string; - userName: string; - userColor?: string; - expiresAt: string; -} - -export interface LiveSessionInfo { - docId: string; - initiatorId: string; - initiatorName: string; - startedAt: string; - expiresAt: string; -} - -export interface DocLocksResponse { - liveSession?: LiveSessionInfo | null; - blockLocks: BlockLockInfo[]; -} - export interface Document { id: string; userId: string; diff --git a/frontend/src/lib/useDocumentSync.ts b/frontend/src/lib/useDocumentSync.ts deleted file mode 100644 index dd6de15..0000000 --- a/frontend/src/lib/useDocumentSync.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { DocumentBlock } from './types'; -import { getToken, api } from './api'; - -export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked'; - -export interface RemoteCursor { - clientId: string; - userId: string; - userName: string; - userColor: string; - blockId: string | null; - selectionStart?: number; - selectionEnd?: number; - updatedAt: number; -} - -export const CURSOR_COLORS = [ - '#3b82f6', // blue - '#10b981', // emerald - '#f59e0b', // amber - '#ec4899', // pink - '#8b5cf6', // purple - '#06b6d4', // cyan - '#f97316', // orange - '#14b8a6', // teal - '#6366f1', // indigo -]; - -export function getUserColor(idOrName: string): string { - if (!idOrName) return CURSOR_COLORS[0]; - let hash = 0; - for (let i = 0; i < idOrName.length; i++) { - hash = (hash << 5) - hash + idOrName.charCodeAt(i); - hash |= 0; - } - return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; -} - -export interface DocSyncEvent { - type: - | 'doc_state' - | 'block_created' - | 'block_updated' - | 'block_deleted' - | 'block_moved' - | 'title_updated' - | 'blocks_replaced' - | 'cursor_moved' - | 'client_left'; - docId?: string; - clientId?: string; - userId?: string; - userName?: string; - userColor?: string; - selectionStart?: number; - selectionEnd?: number; - activeCursors?: RemoteCursor[]; - title?: string; - block?: DocumentBlock; - blockId?: string; - afterBlockId?: string; - newIndex?: number; - content?: string; - level?: number; - checked?: boolean; - collapsed?: boolean; - blockType?: string; - blocks?: DocumentBlock[]; -} - -export function useDocumentSync( - docId: string | null, - onRemoteEvent: (evt: DocSyncEvent) => void, - canEdit: boolean = true, - currentUser?: { id: string; email?: string; displayName?: string } | null, - enabled: boolean = false -) { - const [status, setStatus] = useState(enabled ? 'connecting' : 'idle'); - const [remoteCursors, setRemoteCursors] = useState>({}); - const wsRef = useRef(null); - const clientIdRef = useRef( - `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}` - ); - const onRemoteEventRef = useRef(onRemoteEvent); - onRemoteEventRef.current = onRemoteEvent; - - const docIdRef = useRef(docId); - docIdRef.current = docId; - const canEditRef = useRef(canEdit); - canEditRef.current = canEdit; - const currentUserRef = useRef(currentUser); - currentUserRef.current = currentUser; - - const reconnectTimerRef = useRef(null); - const fallbackTimerRef = useRef(null); - - const localUserName = - currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym'; - const localUserColor = useMemo( - () => getUserColor(currentUser?.id || clientIdRef.current), - [currentUser?.id] - ); - const localUserNameRef = useRef(localUserName); - localUserNameRef.current = localUserName; - const localUserColorRef = useRef(localUserColor); - localUserColorRef.current = localUserColor; - - const pendingMessagesQueueRef = useRef([]); - - const lastCursorSendRef = useRef(0); - const pendingCursorTimerRef = useRef(null); - const pendingCursorPayloadRef = useRef<{ - blockId: string | null; - selectionStart?: number; - selectionEnd?: number; - } | null>(null); - - // Clean up stale remote cursors (> 30s) - useEffect(() => { - const interval = setInterval(() => { - const now = Date.now(); - setRemoteCursors((prev) => { - let hasStale = false; - const next: Record = {}; - for (const [key, cursor] of Object.entries(prev)) { - if (now - cursor.updatedAt < 30000) { - next[key] = cursor; - } else { - hasStale = true; - } - } - return hasStale ? next : prev; - }); - }, 5000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (!docId || !enabled) { - setStatus('idle'); - setRemoteCursors({}); - pendingMessagesQueueRef.current = []; - if (wsRef.current) { - try { - wsRef.current.close(); - } catch {} - wsRef.current = null; - } - return; - } - - pendingMessagesQueueRef.current = []; - let isDisposed = false; - let retryCount = 0; - const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`; - clientIdRef.current = currentClientId; - - const connect = () => { - if (isDisposed) return; - - if (typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true') { - console.info('[DocSync] WebSockets im Firmennetzwerk blockiert (Cache). Live-Transport übersprungen.'); - setStatus('blocked'); - return; - } - - const token = getToken(); - if (!token) { - setStatus('offline'); - return; - } - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent( - docId - )}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent( - currentClientId - )}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent( - localUserColor - )}`; - - setStatus(retryCount === 0 ? 'connecting' : 'reconnecting'); - - let ws: WebSocket; - try { - ws = new WebSocket(wsUrl); - } catch { - setStatus('blocked'); - return; - } - - wsRef.current = ws; - - const handshakeTimer = window.setTimeout(() => { - if (ws.readyState !== WebSocket.OPEN) { - console.warn('[DocSync] WebSocket handshake timed out (corporate firewall/proxy block detected).'); - setStatus('blocked'); - try { - ws.close(); - } catch {} - } - }, 4500); - - ws.onopen = () => { - window.clearTimeout(handshakeTimer); - if (isDisposed) { - ws.close(); - return; - } - retryCount = 0; - setStatus('connected'); - - // Flush any queued messages buffered while connecting or reconnecting - if (pendingMessagesQueueRef.current.length > 0) { - const queue = [...pendingMessagesQueueRef.current]; - pendingMessagesQueueRef.current = []; - for (const msg of queue) { - try { - ws.send(msg); - } catch (err) { - console.warn('[DocSync] Failed to flush queued message:', err); - } - } - } - }; - - ws.onmessage = (event) => { - if (isDisposed) return; - try { - const parsed: DocSyncEvent = JSON.parse(event.data); - if (!parsed || !parsed.type) return; - - // Echo protection: ignore events originating from our own clientId (except initial doc_state) - if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) { - return; - } - - if (parsed.type === 'doc_state') { - if (Array.isArray(parsed.activeCursors)) { - const initialCursors: Record = {}; - for (const c of parsed.activeCursors) { - if (c.clientId && c.clientId !== currentClientId) { - initialCursors[c.clientId] = { - ...c, - updatedAt: c.updatedAt || Date.now(), - }; - } - } - setRemoteCursors(initialCursors); - } - } else if (parsed.type === 'cursor_moved') { - if (parsed.clientId && parsed.clientId !== currentClientId) { - setRemoteCursors((prev) => ({ - ...prev, - [parsed.clientId!]: { - clientId: parsed.clientId!, - userId: parsed.userId || '', - userName: parsed.userName || 'Anonym', - userColor: parsed.userColor || '#3b82f6', - blockId: - parsed.blockId !== undefined - ? parsed.blockId - : prev[parsed.clientId!]?.blockId ?? null, - selectionStart: parsed.selectionStart, - selectionEnd: parsed.selectionEnd, - updatedAt: Date.now(), - }, - })); - } - } else if (parsed.type === 'client_left') { - if (parsed.clientId) { - setRemoteCursors((prev) => { - if (!prev[parsed.clientId!]) return prev; - const next = { ...prev }; - delete next[parsed.clientId!]; - return next; - }); - } - } - - onRemoteEventRef.current(parsed); - } catch { - // Ignore malformed frames - } - }; - - ws.onerror = () => { - // Handled in onclose - }; - - ws.onclose = () => { - window.clearTimeout(handshakeTimer); - if (isDisposed) return; - wsRef.current = null; - retryCount += 1; - if (retryCount >= 2) { - setStatus('blocked'); - } else { - setStatus('reconnecting'); - const delay = Math.min(1000 * Math.pow(1.6, retryCount), 4000); - reconnectTimerRef.current = window.setTimeout(connect, delay); - } - }; - }; - - connect(); - - return () => { - isDisposed = true; - if (reconnectTimerRef.current !== null) { - window.clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - if (fallbackTimerRef.current !== null) { - window.clearTimeout(fallbackTimerRef.current); - fallbackTimerRef.current = null; - } - if (pendingCursorTimerRef.current !== null) { - window.clearTimeout(pendingCursorTimerRef.current); - pendingCursorTimerRef.current = null; - } - if (wsRef.current) { - try { - wsRef.current.onopen = null; - wsRef.current.onmessage = null; - wsRef.current.onerror = null; - wsRef.current.onclose = null; - wsRef.current.close(); - } catch { - // ignore - } - wsRef.current = null; - } - }; - }, [docId, enabled, localUserName, localUserColor]); - - const executeSendCursor = useCallback( - (blockId: string | null, selectionStart?: number, selectionEnd?: number) => { - const ws = wsRef.current; - const activeDocId = docIdRef.current; - if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return; - const payload: DocSyncEvent = { - type: 'cursor_moved', - docId: activeDocId, - clientId: clientIdRef.current, - userId: currentUserRef.current?.id, - userName: localUserNameRef.current, - userColor: localUserColorRef.current, - blockId: blockId || undefined, - selectionStart, - selectionEnd, - }; - try { - ws.send(JSON.stringify(payload)); - } catch { - // Ephemeral cursor, ignore send failure - } - lastCursorSendRef.current = Date.now(); - }, - [] - ); - - const sendCursorMoved = useCallback( - (blockId: string | null, selectionStart?: number, selectionEnd?: number) => { - if (!docIdRef.current) return; - const now = Date.now(); - const elapsed = now - lastCursorSendRef.current; - - // When leaving focus / blur (blockId === null), send immediately - if (blockId === null) { - if (pendingCursorTimerRef.current !== null) { - window.clearTimeout(pendingCursorTimerRef.current); - pendingCursorTimerRef.current = null; - } - pendingCursorPayloadRef.current = null; - executeSendCursor(null); - return; - } - - // Max once per 100ms - if (elapsed >= 100) { - if (pendingCursorTimerRef.current !== null) { - window.clearTimeout(pendingCursorTimerRef.current); - pendingCursorTimerRef.current = null; - } - executeSendCursor(blockId, selectionStart, selectionEnd); - } else { - pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd }; - if (pendingCursorTimerRef.current === null) { - pendingCursorTimerRef.current = window.setTimeout(() => { - pendingCursorTimerRef.current = null; - if (pendingCursorPayloadRef.current) { - executeSendCursor( - pendingCursorPayloadRef.current.blockId, - pendingCursorPayloadRef.current.selectionStart, - pendingCursorPayloadRef.current.selectionEnd - ); - pendingCursorPayloadRef.current = null; - } - }, 100 - elapsed); - } - } - }, - [executeSendCursor] - ); - - const sendRawEvent = useCallback( - (evt: DocSyncEvent) => { - const activeDocId = docIdRef.current; - const activeCanEdit = canEditRef.current; - if (!activeDocId || !activeCanEdit) return; - - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - - const payload: DocSyncEvent = { - ...evt, - docId: activeDocId, - clientId: clientIdRef.current, - }; - try { - ws.send(JSON.stringify(payload)); - } catch (err) { - console.warn('[DocSync] Failed to send WebSocket message:', err); - } - }, - [] - ); - - const sendBlockCreated = useCallback( - (block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => { - sendRawEvent({ - type: 'block_created', - block, - afterBlockId, - }); - }, - [sendRawEvent] - ); - - const sendBlockUpdated = useCallback( - (block: DocumentBlock) => { - sendRawEvent({ - type: 'block_updated', - blockId: block.id, - block, - }); - }, - [sendRawEvent] - ); - - const sendBlockDeleted = useCallback( - (blockId: string, _allBlocks?: DocumentBlock[]) => { - sendRawEvent({ - type: 'block_deleted', - blockId, - }); - }, - [sendRawEvent] - ); - - const sendBlockMoved = useCallback( - (blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => { - sendRawEvent({ - type: 'block_moved', - blockId, - newIndex, - level, - }); - }, - [sendRawEvent] - ); - - const sendTitleUpdated = useCallback( - (title: string) => { - sendRawEvent({ - type: 'title_updated', - title, - }); - }, - [sendRawEvent] - ); - - const sendBlocksReplaced = useCallback( - (blocks: DocumentBlock[]) => { - sendRawEvent({ - type: 'blocks_replaced', - blocks, - }); - }, - [sendRawEvent] - ); - - return { - status, - clientId: clientIdRef.current, - remoteCursors, - sendCursorMoved, - sendBlockCreated, - sendBlockUpdated, - sendBlockDeleted, - sendBlockMoved, - sendTitleUpdated, - sendBlocksReplaced, - }; -} diff --git a/internal/api/server.go b/internal/api/server.go index f2ac2f5..1bd37c7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -31,9 +31,6 @@ type Server struct { } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { - if wsHub != nil && store != nil { - wsHub.SetDocStore(store) - } srv := &Server{ cfg: cfg, store: store, @@ -70,7 +67,6 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/ws", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket) - r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket) r.Get("/api/v1/events", s.handleSSE) r.Post("/api/v1/events/rooms", s.handleSSERooms) @@ -145,13 +141,6 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare) r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare) - // Document Locks & Live-Collaboration Sessions - r.Get("/api/v1/documents/{id}/locks", s.handleGetDocLocks) - r.Post("/api/v1/documents/{id}/blocks/{blockId}/lock", s.handleLockBlock) - r.Post("/api/v1/documents/{id}/blocks/{blockId}/unlock", s.handleUnlockBlock) - r.Post("/api/v1/documents/{id}/live/start", s.handleStartLiveSession) - r.Post("/api/v1/documents/{id}/live/end", s.handleEndLiveSession) - // Shared Lists & Realtime Collaboration r.Get("/api/v1/lists", s.handleGetAllLists) r.Post("/api/v1/lists", s.handleCreateList) @@ -918,18 +907,6 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "Dokument nicht gefunden") return } - if s.wsHub != nil { - if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok { - if liveTitle != "" { - doc.Title = liveTitle - } - if len(liveBlocks) > 0 { - doc.Blocks = liveBlocks - doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks) - doc.Content = doc.MarkdownCache - } - } - } writeJSON(w, http.StatusOK, doc) } @@ -937,19 +914,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - if s.wsHub != nil { - if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusLocked) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", - "lockedBy": ls.InitiatorName, - "isLocked": true, - }) - return - } - } - var req struct { Title *string `json:"title"` Icon *string `json:"icon"` @@ -968,9 +932,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) { - s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks) - } if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") } @@ -981,19 +942,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - if s.wsHub != nil { - if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusLocked) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", - "lockedBy": ls.InitiatorName, - "isLocked": true, - }) - return - } - } - var req domain.DocBlocksPatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -1005,9 +953,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusBadRequest, err.Error()) return } - if s.wsHub != nil { - s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks) - } patchEvent := domain.DocBlocksPatchEvent{ DocID: docID, @@ -1032,9 +977,6 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { docID := chi.URLParam(r, "id") authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID) - if s.wsHub != nil { - s.wsHub.CloseDocRoom(docID) - } if err := s.store.DeleteDocument(userID, docID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -1090,7 +1032,6 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) { } for _, docID := range deletedDocIDs { - s.wsHub.ReleaseDocLock(docID, userID, true) s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID}) } s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath}) @@ -1421,202 +1362,6 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, users) } -// --- DOCUMENT LOCKS & LIVE COLLABORATION SESSIONS --- - -func (s *Server) handleGetDocLocks(w http.ResponseWriter, r *http.Request) { - docID := chi.URLParam(r, "id") - var blockLocks []domain.BlockLockInfo - var liveSession *domain.LiveSessionInfo - if s.wsHub != nil { - blockLocks = s.wsHub.GetDocBlockLocks(docID) - liveSession = s.wsHub.GetLiveSession(docID) - } - if blockLocks == nil { - blockLocks = []domain.BlockLockInfo{} - } - writeJSON(w, http.StatusOK, domain.DocLocksResponse{ - DocID: docID, - BlockLocks: blockLocks, - LiveSession: liveSession, - }) -} - -func (s *Server) handleLockBlock(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - blockID := chi.URLParam(r, "blockId") - - // If live session is active by someone else, reject - if s.wsHub != nil { - if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusLocked) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", - "lockedBy": ls.InitiatorName, - "isLocked": true, - }) - return - } - } - - user, _ := s.store.GetUserByID(userID) - userName := "Anonym" - userColor := "#3b82f6" - if user != nil { - if user.DisplayName != "" { - userName = user.DisplayName - } else { - userName = user.Username - } - } - - var req struct { - UserColor string `json:"userColor"` - TTLSeconds int `json:"ttlSeconds"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - if req.UserColor != "" { - userColor = req.UserColor - } - ttl := 30 * time.Second - if req.TTLSeconds > 5 && req.TTLSeconds <= 120 { - ttl = time.Duration(req.TTLSeconds) * time.Second - } - - if s.wsHub != nil { - lockInfo, ok, existing := s.wsHub.AcquireBlockLock(docID, blockID, userID, userName, userColor, ttl) - if !ok && existing != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Block ist von anderem Benutzer gesperrt", - "lockedBy": existing.UserName, - "lock": existing, - }) - return - } - s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_LOCKED", lockInfo, nil) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_BLOCK_LOCKED", lockInfo) - } - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "lock": lockInfo, - "docId": lockInfo.DocID, - "blockId": lockInfo.BlockID, - "userId": lockInfo.UserID, - "userName": lockInfo.UserName, - "userColor": lockInfo.UserColor, - "expiresAt": lockInfo.ExpiresAt, - }) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleUnlockBlock(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - blockID := chi.URLParam(r, "blockId") - - if s.wsHub != nil { - released := s.wsHub.ReleaseBlockLock(docID, blockID, userID) - if released { - evt := map[string]string{ - "docId": docID, - "blockId": blockID, - "userId": userID, - } - s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_UNLOCKED", evt, nil) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_BLOCK_UNLOCKED", evt) - } - } - } - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleStartLiveSession(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - - user, _ := s.store.GetUserByID(userID) - userName := "Anonym" - if user != nil { - if user.DisplayName != "" { - userName = user.DisplayName - } else { - userName = user.Username - } - } - - ttl := 60 * time.Second // 1 min lease with heartbeat while active - if s.wsHub != nil { - info, ok, existing := s.wsHub.StartLiveSession(docID, userID, userName, ttl) - if !ok && existing != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Live-Sitzung bereits von anderem Benutzer aktiv", - "session": existing, - "initiator": existing.InitiatorName, - }) - return - } - s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_STARTED", info, nil) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_LIVE_STARTED", info) - } - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "session": info, - "docId": info.DocID, - "initiatorId": info.InitiatorID, - "initiatorName": info.InitiatorName, - "startedAt": info.StartedAt, - "expiresAt": info.ExpiresAt, - }) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleEndLiveSession(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - - doc, _ := s.store.GetDocumentByID(userID, docID) - isOwner := doc != nil && (doc.IsOwner || doc.Permission == "owner" || doc.UserID == userID) - - if s.wsHub != nil { - if isOwner { - s.wsHub.ForceEndLiveSession(docID) - } else { - s.wsHub.EndLiveSession(docID, userID) - } - evt := map[string]string{ - "docId": docID, - "userId": userID, - } - s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", evt, nil) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_LIVE_ENDED", evt) - } - } - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -// --- DOCUMENT REALTIME WEBSOCKET ROOM --- - func (s *Server) authenticateUserFromReq(r *http.Request) string { token := r.URL.Query().Get("token") if token == "" { @@ -1643,46 +1388,6 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string { return "" } -// GET /api/v1/ws/docs/{id} -func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Sec-Websocket-Key") != "" { - if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") { - r.Header.Set("Connection", "Upgrade") - } - if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") { - r.Header.Set("Upgrade", "websocket") - } - } - - docID := chi.URLParam(r, "id") - if docID == "" { - writeError(w, http.StatusBadRequest, "docId erforderlich") - return - } - - userID := s.authenticateUserFromReq(r) - if userID == "" { - userID = auth.GetUserID(r.Context()) - } - if userID == "" { - userID = "anonymous" - } - - canWrite := false - if userID != "anonymous" { - doc, err := s.store.GetDocumentByID(userID, docID) - if err != nil { - writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument") - return - } - canWrite = doc.IsOwner || doc.Permission == "owner" || doc.Permission == "write" - } - - clientID := r.URL.Query().Get("clientId") - room := s.wsHub.GetOrCreateDocRoom(docID) - room.ServeDocWS(w, r, userID, clientID, canWrite) -} - // --- REALTIME LISTS & SHARING --- func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 5bb19cb..1b84a14 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -841,93 +841,6 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) { } } -func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "docroom_test.db") - - store, err := storage.NewStore(dbPath) - if err != nil { - t.Fatalf("NewStore failed: %v", err) - } - defer store.Close() - - cfg := &config.Config{ - JWTSecret: "test-jwt-secret-key", - AllowRegistration: true, - } - hub := ws.NewHub() - srv := NewServer(cfg, store, hub, nil) - router := srv.Router() - - user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member") - if err != nil { - t.Fatalf("CreateUser failed: %v", err) - } - token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role, user.TokenVersion) - - doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false) - if err != nil { - t.Fatalf("CreateDocument failed: %v", err) - } - - // 1. Mutate in-memory DocRoom before 1.5s flush fires - room := hub.GetOrCreateDocRoom(doc.ID) - newContent := "Live RAM Block #ramtag" - newLevel := 1 - room.HandleEvent(ws.DocSyncEvent{ - Type: "title_updated", - ClientID: "cli_1", - Title: "RAM Updated Title", - }, nil) - room.HandleEvent(ws.DocSyncEvent{ - Type: "block_created", - ClientID: "cli_1", - AfterBlockID: doc.Blocks[0].ID, - Block: &domain.DocumentBlock{ - ID: "blk_ram_2", - Type: "text", - Content: newContent, - Level: newLevel, - }, - }, nil) - - // 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state - req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil) - req.Header.Set("Authorization", "Bearer "+token) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String()) - } - var liveDoc domain.Document - _ = json.Unmarshal(rec.Body.Bytes(), &liveDoc) - if liveDoc.Title != "RAM Updated Title" { - t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title) - } - if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent { - t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks) - } - - // 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache - room.Flush() - - persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID) - if err != nil { - t.Fatalf("GetDocumentByID failed: %v", err) - } - if persistedDoc.Title != "RAM Updated Title" { - t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title) - } - if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") { - t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache) - } - tags, _ := store.GetWorkspaceTags(user.ID) - if len(tags) != 1 || tags[0].Tag != "ramtag" { - t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags) - } -} - func TestSSEGlobalEndpoint(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test_sse.db") store, err := storage.NewStore(dbPath) @@ -1295,94 +1208,54 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) { } } -func TestBlockLockingAndLiveSessionGuards(t *testing.T) { +func TestDocumentSharingStrictlyReadOnly(t *testing.T) { srv, store, _ := setupTestServer(t) router := srv.Router() - userA, _ := store.CreateUser("user_a_lock@example.com", "hash", "User A", "member") + userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member") tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion) - userB, _ := store.CreateUser("user_b_lock@example.com", "hash", "User B", "member") + userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member") tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion) - // Create document owned by User A, shared with User B + // Create document owned by User A blocks := []domain.DocumentBlock{ {ID: "b1", Type: "bullet", Content: "Row 1", Level: 0}, {ID: "b2", Type: "bullet", Content: "Row 2", Level: 0}, } - doc, err := store.CreateDocument(userA.ID, "", "Lock Test Doc", "📝", "", blocks, nil, false) + doc, err := store.CreateDocument(userA.ID, "", "Read Only Test Doc", "📝", "", blocks, nil, false) if err != nil { t.Fatalf("CreateDocument failed: %v", err) } - _, _ = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write") - // 1. Initial locks should be empty - req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/locks", nil) - req.Header.Set("Authorization", "Bearer "+tokenA) + // Share with User B (forced read-only) + _, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read") + if err != nil { + t.Fatalf("CreateDocShare failed: %v", err) + } + + // 1. User B can read document + req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil) + req.Header.Set("Authorization", "Bearer "+tokenB) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("get locks failed: %d", rec.Code) - } - var locksResp domain.DocLocksResponse - _ = json.Unmarshal(rec.Body.Bytes(), &locksResp) - if len(locksResp.BlockLocks) != 0 || locksResp.LiveSession != nil { - t.Fatalf("expected 0 locks initially, got %v", locksResp) + t.Fatalf("User B should be able to read doc, got status: %d", rec.Code) } - // 2. User A locks block b1 - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) - req.Header.Set("Authorization", "Bearer "+tokenA) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user A lock b1 failed: %d, body: %s", rec.Code, rec.Body.String()) - } - - // 3. User B tries to lock b1 -> must receive 409 Conflict - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) + // 2. User B tries to update document via PUT -> must fail (not owner) + newTitle := "Hacked Title" + updateBody, _ := json.Marshal(map[string]interface{}{"title": newTitle}) + req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(updateBody)) req.Header.Set("Authorization", "Bearer "+tokenB) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("expected 409 Conflict for user B on b1, got %d", rec.Code) + if rec.Code == http.StatusOK { + t.Fatalf("expected error for User B updating document, got 200 OK") } - // 4. User A unlocks b1 - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil) - req.Header.Set("Authorization", "Bearer "+tokenA) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user A unlock b1 failed: %d", rec.Code) - } - - // 5. User B can now lock b1 - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) - req.Header.Set("Authorization", "Bearer "+tokenB) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user B lock b1 should succeed now, got %d", rec.Code) - } - // User B unlocks b1 - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil) - req.Header.Set("Authorization", "Bearer "+tokenB) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - - // 6. User A starts a Live-Session on doc - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/start", nil) - req.Header.Set("Authorization", "Bearer "+tokenA) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user A start live session failed: %d, body: %s", rec.Code, rec.Body.String()) - } - - // 7. User B tries to patch document blocks while User A's live session is active -> must receive 423 Locked! + // 3. User B tries to patch document blocks -> must fail (not owner) patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{ Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}}, }) @@ -1391,27 +1264,20 @@ func TestBlockLockingAndLiveSessionGuards(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) - if rec.Code != http.StatusLocked { - t.Fatalf("expected 423 Locked for User B while live session is active, got %d, body: %s", rec.Code, rec.Body.String()) + if rec.Code == http.StatusOK { + t.Fatalf("expected error for User B patching document blocks, got 200 OK") } - // 8. User A ends the Live-Session - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/end", nil) + // 4. User A (owner) can successfully update document + ownerTitle := "Owner Updated Title" + ownerBody, _ := json.Marshal(map[string]interface{}{"title": ownerTitle}) + req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(ownerBody)) req.Header.Set("Authorization", "Bearer "+tokenA) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user A end live session failed: %d", rec.Code) - } - - // 9. User B can now edit again - req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody)) - req.Header.Set("Authorization", "Bearer "+tokenB) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("User B edit should succeed after live session ended, got %d, body: %s", rec.Code, rec.Body.String()) + t.Fatalf("owner should be able to update document, got %d", rec.Code) } } diff --git a/internal/domain/models.go b/internal/domain/models.go index 46c1934..410b229 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -337,36 +337,6 @@ type DocShare struct { CreatedAt string `json:"createdAt"` } -type DocLockInfo struct { - DocID string `json:"docId"` - UserID string `json:"userId"` - UserName string `json:"userName"` - ExpiresAt string `json:"expiresAt"` -} - -type BlockLockInfo struct { - DocID string `json:"docId"` - BlockID string `json:"blockId"` - UserID string `json:"userId"` - UserName string `json:"userName"` - UserColor string `json:"userColor,omitempty"` - ExpiresAt string `json:"expiresAt"` -} - -type LiveSessionInfo struct { - DocID string `json:"docId"` - InitiatorID string `json:"initiatorId"` - InitiatorName string `json:"initiatorName"` - StartedAt string `json:"startedAt"` - ExpiresAt string `json:"expiresAt"` -} - -type DocLocksResponse struct { - DocID string `json:"docId"` - BlockLocks []BlockLockInfo `json:"blockLocks"` - LiveSession *LiveSessionInfo `json:"liveSession,omitempty"` -} - type Document struct { ID string `json:"id"` UserID string `json:"userId"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 0d41584..b453bdc 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -414,6 +414,10 @@ func (s *Store) migrate() error { // Backfill username for existing accounts where username is empty _, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`) + // Ensure all existing document shares are strictly read-only + _, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`) + _, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`) + // Default system settings (public registration disabled by default; invite or admin creation required) _, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES ('sharing_mode', 'teams_only'), @@ -1526,7 +1530,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa if err != nil { return nil, nil, err } - if !doc.IsOwner && doc.Permission != "write" { + if !doc.IsOwner { return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") } @@ -2274,7 +2278,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil - if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange && !isOnlyFolderMove { + if !doc.IsOwner && !isOnlyPinChange && !isOnlyFolderMove { return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") } @@ -2908,7 +2912,7 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do // Target is NOT inside a shared folder (personal / standalone) if doc.SharedFolderID != "" { // Moving OUT of a shared folder into a personal folder - if !doc.IsOwner && doc.Permission != "write" { + if !doc.IsOwner { return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben") } _, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`, @@ -3564,8 +3568,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri 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 !doc.IsOwner { + return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") } cleanTeamID := strings.TrimSpace(teamID) @@ -3575,9 +3579,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri return nil, fmt.Errorf("team nicht gefunden") } - if permission != "read" && permission != "write" { - permission = "read" - } + // Document sharing is strictly read-only + permission = "read" now := domain.NowISO() shareID := domain.NewID("share") @@ -3609,20 +3612,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi 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 !doc.IsOwner { + return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") } cleanTarget := strings.TrimSpace(targetUserIdentifier) // If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare if strings.HasPrefix(cleanTarget, "team_") { - return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) + return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, "read") } - if permission != "read" && permission != "write" { - permission = "read" - } + // Document sharing is strictly read-only + permission = "read" // Resolve target user by ID, username, or Email var targetUser *domain.User diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index f5d2b63..22628d9 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -173,16 +173,10 @@ func TestStoreLifecycle(t *testing.T) { 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 + // user2 is a read-only collaborator -> editing must fail _, 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) + if err == nil { + t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded") } // Remove share @@ -551,8 +545,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { t.Fatalf("MoveDocument by userB failed: %v", err) } - // User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3) - // Simultaneously, User A edits b1 and adds b4 using a slightly stale order + // User B is a read-only collaborator -> PatchDocumentBlocks must fail _, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{ Upserted: []domain.DocumentBlock{ {ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0}, @@ -560,8 +553,8 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { }, BlockOrder: []string{"b1", "b2", "b3"}, }) - if err != nil { - t.Fatalf("PatchDocumentBlocks by userB failed: %v", err) + if err == nil { + t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded") } // User A's client only knew about b1, b2 when sending its patch (adds b4) @@ -576,9 +569,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { t.Fatalf("PatchDocumentBlocks by userA failed: %v", err) } - // Verify NO blocks were lost! All 4 blocks (b1, b2, b3, b4) must exist with both users' edits preserved - if len(patchedDocA.Blocks) != 4 { - t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks) + // Verify User A's blocks are preserved: b1, b2, b4 (3 blocks) + if len(patchedDocA.Blocks) != 3 { + t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks) } blockContent := make(map[string]string) for _, b := range patchedDocA.Blocks { @@ -587,12 +580,6 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { if blockContent["b1"] != "Zeile 1 von A bearbeitet" { t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"]) } - if blockContent["b2"] != "Zeile 2 von B bearbeitet" { - t.Fatalf("expected b2 updated by B to be preserved, got %q", blockContent["b2"]) - } - if blockContent["b3"] != "Zeile 3 neu von B" { - t.Fatalf("expected b3 added by B to be preserved even with stale order from A, got %q", blockContent["b3"]) - } if blockContent["b4"] != "Zeile 4 neu von A" { t.Fatalf("expected b4 added by A, got %q", blockContent["b4"]) } diff --git a/internal/ws/docroom.go b/internal/ws/docroom.go deleted file mode 100644 index ed923e3..0000000 --- a/internal/ws/docroom.go +++ /dev/null @@ -1,736 +0,0 @@ -package ws - -import ( - "encoding/json" - "log/slog" - "net/http" - "sync" - "time" - - "github.com/gorilla/websocket" - "github.com/sandrodach/mindsift/internal/domain" -) - -// CursorPresence represents an active user's ephemeral cursor position in a document room. -type CursorPresence struct { - ClientID string `json:"clientId"` - UserID string `json:"userId"` - UserName string `json:"userName"` - UserColor string `json:"userColor"` - BlockID string `json:"blockId,omitempty"` - SelectionStart *int `json:"selectionStart,omitempty"` - SelectionEnd *int `json:"selectionEnd,omitempty"` - UpdatedAt int64 `json:"updatedAt"` -} - -// DocWSClient represents an active WebSocket connection to a specific document room. -type DocWSClient struct { - hub *Hub - room *DocRoom - conn *websocket.Conn - userID string - clientID string - canWrite bool - send chan []byte -} - -// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}. -type DocSyncEvent struct { - Type string `json:"type"` - DocID string `json:"docId,omitempty"` - ClientID string `json:"clientId,omitempty"` - UserID string `json:"userId,omitempty"` - UserName string `json:"userName,omitempty"` - UserColor string `json:"userColor,omitempty"` - SelectionStart *int `json:"selectionStart,omitempty"` - SelectionEnd *int `json:"selectionEnd,omitempty"` - ActiveCursors []CursorPresence `json:"activeCursors,omitempty"` - Title string `json:"title,omitempty"` - Block *domain.DocumentBlock `json:"block,omitempty"` - BlockID string `json:"blockId,omitempty"` - AfterBlockID string `json:"afterBlockId,omitempty"` - NewIndex *int `json:"newIndex,omitempty"` - Content *string `json:"content,omitempty"` - Level *int `json:"level,omitempty"` - Checked *bool `json:"checked,omitempty"` - Collapsed *bool `json:"collapsed,omitempty"` - BlockType *string `json:"blockType,omitempty"` - Blocks []domain.DocumentBlock `json:"blocks,omitempty"` -} - -// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document. -type DocRoom struct { - mu sync.Mutex - docID string - hub *Hub - title string - blocks []domain.DocumentBlock - loaded bool - wsClients map[*DocWSClient]bool - activeCursors map[string]*CursorPresence - debounceTimer *time.Timer - dirty bool -} - -// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite. -func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom { - h.mu.Lock() - if h.docRooms == nil { - h.docRooms = make(map[string]*DocRoom) - } - room, exists := h.docRooms[docID] - if !exists { - room = &DocRoom{ - docID: docID, - hub: h, - wsClients: make(map[*DocWSClient]bool), - activeCursors: make(map[string]*CursorPresence), - } - h.docRooms[docID] = room - } - store := h.docStore - h.mu.Unlock() - - room.mu.Lock() - if !room.loaded && store != nil { - title, blocks, err := store.LoadDocumentState(docID) - if err == nil { - room.title = title - room.blocks = normalizeRoomBlocks(docID, blocks) - room.loaded = true - } - } - room.mu.Unlock() - - return room -} - -// GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM. -func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) { - h.mu.RLock() - room, exists := h.docRooms[docID] - h.mu.RUnlock() - if !exists || room == nil { - return "", nil, false - } - - room.mu.Lock() - defer room.mu.Unlock() - if !room.loaded { - return "", nil, false - } - blocksCopy := make([]domain.DocumentBlock, len(room.blocks)) - copy(blocksCopy, room.blocks) - return room.title, blocksCopy, true -} - -// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH). -func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) { - h.mu.RLock() - room, exists := h.docRooms[docID] - h.mu.RUnlock() - if !exists || room == nil { - return - } - - room.mu.Lock() - if title != "" { - room.title = title - } - if blocks != nil { - room.blocks = normalizeRoomBlocks(docID, blocks) - } - room.loaded = true - room.mu.Unlock() -} - -// CloseDocRoom removes a document room from memory when a document is deleted. -func (h *Hub) CloseDocRoom(docID string) { - h.mu.Lock() - room, exists := h.docRooms[docID] - if exists { - delete(h.docRooms, docID) - } - h.mu.Unlock() - - if exists && room != nil { - room.mu.Lock() - if room.debounceTimer != nil { - room.debounceTimer.Stop() - } - room.dirty = false - for c := range room.wsClients { - _ = c.conn.Close() - } - room.wsClients = make(map[*DocWSClient]bool) - room.mu.Unlock() - } -} - -func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock { - if len(blocks) == 0 { - return []domain.DocumentBlock{ - { - ID: docID + "-b0", - DocumentID: docID, - Type: "text", - Content: "", - Level: 0, - Position: 1000, - }, - } - } - out := make([]domain.DocumentBlock, len(blocks)) - for i, b := range blocks { - if b.ID == "" { - b.ID = domain.NewID("blk") - } - b.DocumentID = docID - if b.Type == "" { - b.Type = "text" - } - if b.Level < 0 { - b.Level = 0 - } - b.Position = float64((i + 1) * 1000) - out[i] = b - } - return out -} - -// GetStateSnapshot returns a copy of the room's current live in-memory title and blocks. -func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) { - r.mu.Lock() - defer r.mu.Unlock() - blocksCopy := make([]domain.DocumentBlock, len(r.blocks)) - copy(blocksCopy, r.blocks) - return r.title, blocksCopy -} - -// HandleEvent applies a granular block or title mutation to the in-memory room, -// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection), -// and resets the 1.5-second inactivity debounce timer for SQLite persistence. -func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) { - r.mu.Lock() - - evt.DocID = r.docID - if sender != nil { - if evt.ClientID == "" { - evt.ClientID = sender.clientID - } - evt.UserID = sender.userID - } - - mutated := false - - switch evt.Type { - case "cursor_moved": - if evt.ClientID != "" { - cp := &CursorPresence{ - ClientID: evt.ClientID, - UserID: evt.UserID, - UserName: evt.UserName, - UserColor: evt.UserColor, - BlockID: evt.BlockID, - SelectionStart: evt.SelectionStart, - SelectionEnd: evt.SelectionEnd, - UpdatedAt: time.Now().UnixMilli(), - } - if r.activeCursors == nil { - r.activeCursors = make(map[string]*CursorPresence) - } - r.activeCursors[evt.ClientID] = cp - - msgBytes, err := json.Marshal(evt) - if err == nil { - for client := range r.wsClients { - if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) { - continue - } - if evt.ClientID != "" && client.clientID == evt.ClientID { - continue - } - select { - case client.send <- msgBytes: - default: - } - } - } - } - r.mu.Unlock() - return - - case "title_updated": - if evt.Title != "" || r.title != evt.Title { - r.title = evt.Title - mutated = true - } - - case "block_created": - if evt.Block != nil { - newBlock := *evt.Block - if newBlock.ID == "" { - newBlock.ID = domain.NewID("blk") - } - newBlock.DocumentID = r.docID - if newBlock.Type == "" { - newBlock.Type = "text" - } - if newBlock.Level < 0 { - newBlock.Level = 0 - } - - // Avoid duplicate ID if already created - existsIdx := -1 - for i, b := range r.blocks { - if b.ID == newBlock.ID { - existsIdx = i - break - } - } - - if existsIdx >= 0 { - r.blocks[existsIdx] = newBlock - } else { - insertIdx := len(r.blocks) - if evt.AfterBlockID != "" { - for i, b := range r.blocks { - if b.ID == evt.AfterBlockID { - insertIdx = i + 1 - break - } - } - } else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) { - insertIdx = *evt.NewIndex - } - - updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1) - updated = append(updated, r.blocks[:insertIdx]...) - updated = append(updated, newBlock) - updated = append(updated, r.blocks[insertIdx:]...) - r.blocks = normalizeRoomBlocks(r.docID, updated) - } - evt.Block = &newBlock - mutated = true - } - - case "block_updated": - targetID := evt.BlockID - if targetID == "" && evt.Block != nil { - targetID = evt.Block.ID - } - if targetID != "" { - found := false - for i := range r.blocks { - if r.blocks[i].ID == targetID { - found = true - if evt.Block != nil { - r.blocks[i].Content = evt.Block.Content - r.blocks[i].Level = evt.Block.Level - if evt.Block.Type != "" { - r.blocks[i].Type = evt.Block.Type - } - r.blocks[i].Checked = evt.Block.Checked - r.blocks[i].Collapsed = evt.Block.Collapsed - r.blocks[i].Lang = evt.Block.Lang - } else { - if evt.Content != nil { - r.blocks[i].Content = *evt.Content - } - if evt.Level != nil { - lvl := *evt.Level - if lvl < 0 { - lvl = 0 - } - r.blocks[i].Level = lvl - } - if evt.Checked != nil { - r.blocks[i].Checked = *evt.Checked - } - if evt.Collapsed != nil { - r.blocks[i].Collapsed = *evt.Collapsed - } - if evt.BlockType != nil && *evt.BlockType != "" { - r.blocks[i].Type = *evt.BlockType - } - } - bCopy := r.blocks[i] - evt.Block = &bCopy - evt.BlockID = targetID - mutated = true - break - } - } - if !found && evt.Block != nil { - newBlock := *evt.Block - newBlock.DocumentID = r.docID - if newBlock.Type == "" { - newBlock.Type = "text" - } - if newBlock.Level < 0 { - newBlock.Level = 0 - } - r.blocks = append(r.blocks, newBlock) - r.blocks = normalizeRoomBlocks(r.docID, r.blocks) - bCopy := newBlock - evt.Block = &bCopy - evt.BlockID = targetID - mutated = true - } - } - - case "block_deleted": - if evt.BlockID != "" { - filtered := make([]domain.DocumentBlock, 0, len(r.blocks)) - for _, b := range r.blocks { - if b.ID != evt.BlockID { - filtered = append(filtered, b) - } - } - r.blocks = normalizeRoomBlocks(r.docID, filtered) - mutated = true - } - - case "block_moved": - if evt.BlockID != "" && evt.NewIndex != nil { - fromIdx := -1 - for i, b := range r.blocks { - if b.ID == evt.BlockID { - fromIdx = i - break - } - } - if fromIdx >= 0 { - moving := r.blocks[fromIdx] - if evt.Level != nil { - lvl := *evt.Level - if lvl < 0 { - lvl = 0 - } - moving.Level = lvl - } - without := make([]domain.DocumentBlock, 0, len(r.blocks)-1) - without = append(without, r.blocks[:fromIdx]...) - without = append(without, r.blocks[fromIdx+1:]...) - - toIdx := *evt.NewIndex - if toIdx < 0 { - toIdx = 0 - } - if toIdx > len(without) { - toIdx = len(without) - } - - reordered := make([]domain.DocumentBlock, 0, len(r.blocks)) - reordered = append(reordered, without[:toIdx]...) - reordered = append(reordered, moving) - reordered = append(reordered, without[toIdx:]...) - r.blocks = normalizeRoomBlocks(r.docID, reordered) - mutated = true - } - } - - case "blocks_replaced": - if evt.Blocks != nil { - r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks) - evt.Blocks = r.blocks - mutated = true - } - } - - if !mutated { - r.mu.Unlock() - return - } - - r.dirty = true - r.loaded = true - - msgBytes, err := json.Marshal(evt) - if err == nil { - for client := range r.wsClients { - // Strict Echo Protection: never send back to the originating connection or clientId - if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) { - continue - } - if evt.ClientID != "" && client.clientID == evt.ClientID { - continue - } - select { - case client.send <- msgBytes: - default: - slog.Warn("Doc room WS client send buffer full, dropping message", - "doc_id", r.docID, - "client_id", client.clientID, - "user_id", client.userID, - ) - } - } - } - - r.scheduleDebouncedFlushLocked() - r.mu.Unlock() -} - -func (r *DocRoom) scheduleDebouncedFlushLocked() { - if r.debounceTimer != nil { - r.debounceTimer.Stop() - } - r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() { - r.Flush() - }) -} - -// Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite. -func (r *DocRoom) Flush() { - r.mu.Lock() - if !r.dirty { - r.mu.Unlock() - return - } - if r.debounceTimer != nil { - r.debounceTimer.Stop() - r.debounceTimer = nil - } - - docID := r.docID - title := r.title - blocksCopy := make([]domain.DocumentBlock, len(r.blocks)) - copy(blocksCopy, r.blocks) - r.dirty = false - r.mu.Unlock() - - tagsCount := 0 - for _, b := range blocksCopy { - tagsCount += len(domain.ExtractTags(b.Content)) - } - - start := time.Now() - r.hub.mu.RLock() - store := r.hub.docStore - r.hub.mu.RUnlock() - - if store != nil { - if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil { - slog.Error("SQLite document flush failed", - "doc_id", docID, - "error", err, - ) - } else { - durationMs := float64(time.Since(start).Microseconds()) / 1000.0 - slog.Debug("SQLite document flushed", - "doc_id", docID, - "blocks_count", len(blocksCopy), - "tags_count", tagsCount, - "duration_ms", durationMs, - ) - } - } -} - -// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id} -func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) { - conn, err := upgrader.Upgrade(w, req, nil) - if err != nil { - slog.Error("WebSocket upgrade error", "doc_id", r.docID, "error", err) - return - } - - if clientID == "" { - clientID = req.URL.Query().Get("clientId") - } - if clientID == "" { - clientID = domain.NewID("cli") - } - - userName := req.URL.Query().Get("userName") - userColor := req.URL.Query().Get("userColor") - - client := &DocWSClient{ - hub: r.hub, - room: r, - conn: conn, - userID: userID, - clientID: clientID, - canWrite: canWrite, - send: make(chan []byte, 256), - } - - r.mu.Lock() - r.wsClients[client] = true - activeClients := len(r.wsClients) - if r.activeCursors == nil { - r.activeCursors = make(map[string]*CursorPresence) - } - if userName != "" { - r.activeCursors[clientID] = &CursorPresence{ - ClientID: clientID, - UserID: userID, - UserName: userName, - UserColor: userColor, - UpdatedAt: time.Now().UnixMilli(), - } - } - title := r.title - blocksCopy := make([]domain.DocumentBlock, len(r.blocks)) - copy(blocksCopy, r.blocks) - - activeList := make([]CursorPresence, 0, len(r.activeCursors)) - for _, cp := range r.activeCursors { - if cp != nil { - activeList = append(activeList, *cp) - } - } - r.mu.Unlock() - - slog.Debug("WS client joined doc room", - "doc_id", r.docID, - "client_id", clientID, - "user_id", userID, - "active_clients", activeClients, - ) - - // Immediately send live in-memory state and current cursors to the newly connected client - initEvt := DocSyncEvent{ - Type: "doc_state", - DocID: r.docID, - ClientID: clientID, - Title: title, - Blocks: blocksCopy, - ActiveCursors: activeList, - } - if initBytes, err := json.Marshal(initEvt); err == nil { - client.send <- initBytes - } - - // If userName was provided, broadcast initial presence to existing clients in room - if userName != "" { - joinEvt := DocSyncEvent{ - Type: "cursor_moved", - DocID: r.docID, - ClientID: clientID, - UserID: userID, - UserName: userName, - UserColor: userColor, - } - if joinBytes, err := json.Marshal(joinEvt); err == nil { - r.mu.Lock() - for c := range r.wsClients { - if c != client && c.clientID != clientID { - select { - case c.send <- joinBytes: - default: - } - } - } - r.mu.Unlock() - } - } - - go client.writePump() - go client.readPump() -} - -func (c *DocWSClient) readPump() { - defer func() { - c.room.mu.Lock() - delete(c.room.wsClients, c) - delete(c.room.activeCursors, c.clientID) - remaining := len(c.room.wsClients) - - // Broadcast client_left to all remaining clients in room - leftEvt := DocSyncEvent{ - Type: "client_left", - DocID: c.room.docID, - ClientID: c.clientID, - UserID: c.userID, - } - if leftBytes, err := json.Marshal(leftEvt); err == nil { - for client := range c.room.wsClients { - if client != c && client.clientID != c.clientID { - select { - case client.send <- leftBytes: - default: - } - } - } - } - c.room.mu.Unlock() - - slog.Debug("WS client left doc room", - "doc_id", c.room.docID, - "client_id", c.clientID, - "user_id", c.userID, - "active_clients", remaining, - ) - - _ = c.conn.Close() - - // Flush immediately when the last client leaves the document room - if remaining == 0 { - c.room.Flush() - } - }() - - c.conn.SetReadLimit(4 * 1024 * 1024) - _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - c.conn.SetPongHandler(func(string) error { - _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - return nil - }) - - for { - _, message, err := c.conn.ReadMessage() - if err != nil { - break - } - - var evt DocSyncEvent - if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" { - targetBlockID := evt.BlockID - if targetBlockID == "" && evt.Block != nil { - targetBlockID = evt.Block.ID - } - - slog.Debug("WS doc event received", - "type", evt.Type, - "doc_id", c.room.docID, - "block_id", targetBlockID, - "sender_client_id", c.clientID, - ) - - // Read-only clients can still move their cursor and send presence updates - if !c.canWrite && evt.Type != "cursor_moved" { - continue - } - c.room.HandleEvent(evt, c) - } - } -} - -func (c *DocWSClient) writePump() { - ticker := time.NewTicker(25 * time.Second) - defer func() { - ticker.Stop() - _ = c.conn.Close() - }() - - for { - select { - case message, ok := <-c.send: - _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if !ok { - _ = c.conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { - return - } - case <-ticker.C: - _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { - return - } - } - } -} diff --git a/internal/ws/docroom_test.go b/internal/ws/docroom_test.go deleted file mode 100644 index 7cde8df..0000000 --- a/internal/ws/docroom_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package ws - -import ( - "testing" - "time" -) - -func TestDocRoomCursorPresenceAndEchoProtection(t *testing.T) { - hub := NewHub() - room := hub.GetOrCreateDocRoom("doc_test_presence") - - c1 := &DocWSClient{ - hub: hub, - room: room, - userID: "user_1", - clientID: "cli_1", - canWrite: true, - send: make(chan []byte, 10), - } - c2 := &DocWSClient{ - hub: hub, - room: room, - userID: "user_2", - clientID: "cli_2", - canWrite: false, // read-only viewer - send: make(chan []byte, 10), - } - - room.mu.Lock() - room.wsClients[c1] = true - room.wsClients[c2] = true - room.mu.Unlock() - - // 1. Send cursor_moved from c1 - selStart := 5 - selEnd := 5 - room.HandleEvent(DocSyncEvent{ - Type: "cursor_moved", - DocID: "doc_test_presence", - ClientID: "cli_1", - UserID: "user_1", - UserName: "Alice", - UserColor: "#3b82f6", - BlockID: "blk_123", - SelectionStart: &selStart, - SelectionEnd: &selEnd, - }, c1) - - // c1 should NOT have received its own echo - select { - case <-c1.send: - t.Fatal("c1 received echo of its own cursor_moved event!") - default: - } - - // c2 SHOULD receive the event - select { - case msg := <-c2.send: - if len(msg) == 0 { - t.Fatal("empty message received by c2") - } - case <-time.After(500 * time.Millisecond): - t.Fatal("timed out waiting for cursor_moved event on c2") - } - - // Room should have registered c1's presence - room.mu.Lock() - cp, exists := room.activeCursors["cli_1"] - room.mu.Unlock() - - if !exists || cp == nil { - t.Fatal("expected active cursor for cli_1 in room") - } - if cp.UserName != "Alice" || cp.BlockID != "blk_123" { - t.Fatalf("unexpected cursor data: %+v", cp) - } - - // Ephemeral cursor events must NOT mark the room dirty for disk flush - room.mu.Lock() - dirty := room.dirty - room.mu.Unlock() - if dirty { - t.Fatal("cursor_moved should not mark room dirty") - } -} diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 3453469..533815d 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -84,37 +84,6 @@ func (c *SSEClient) trySend(data []byte) { } } -type docLockItem struct { - docID string - userID string - userName string - lockedAt time.Time - expiresAt time.Time -} - -type blockLockItem struct { - docID string - blockID string - userID string - userName string - userColor string - lockedAt time.Time - expiresAt time.Time -} - -type liveSessionItem struct { - docID string - initiatorID string - initiatorName string - startedAt time.Time - expiresAt time.Time -} - -type DocStore interface { - LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) - FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error -} - type Hub struct { mu sync.RWMutex rooms map[string]map[*Client]bool @@ -123,37 +92,18 @@ type Hub struct { sseClients map[*SSEClient]bool sseUserRooms map[string]map[*SSEClient]bool sseRooms map[string]map[*SSEClient]bool - locks map[string]*docLockItem - blockLocks map[string]*blockLockItem // key: docID + ":" + blockID - liveSessions map[string]*liveSessionItem // key: docID - docRooms map[string]*DocRoom - docStore DocStore - stopCleanup chan struct{} closed bool } func NewHub() *Hub { - h := &Hub{ + return &Hub{ rooms: make(map[string]map[*Client]bool), userRooms: make(map[string]map[*Client]bool), allClients: make(map[*Client]bool), sseClients: make(map[*SSEClient]bool), sseUserRooms: make(map[string]map[*SSEClient]bool), sseRooms: make(map[string]map[*SSEClient]bool), - locks: make(map[string]*docLockItem), - blockLocks: make(map[string]*blockLockItem), - liveSessions: make(map[string]*liveSessionItem), - docRooms: make(map[string]*DocRoom), - stopCleanup: make(chan struct{}), } - go h.cleanupLocksLoop() - return h -} - -func (h *Hub) SetDocStore(store DocStore) { - h.mu.Lock() - defer h.mu.Unlock() - h.docStore = store } func (h *Hub) Register(c *Client) { @@ -193,7 +143,6 @@ func (h *Hub) Unregister(c *Client) { } } - var docRoomsLeft []string if c.subscribedRooms != nil { for rID := range c.subscribedRooms { if room, ok := h.rooms[rID]; ok { @@ -202,52 +151,19 @@ func (h *Hub) Unregister(c *Client) { delete(h.rooms, rID) } } - if strings.HasPrefix(rID, "doc:") { - docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:")) - } } } - userRemaining := 0 if c.userID != "" && c.userID != "anonymous" { if uRoom, ok := h.userRooms[c.userID]; ok { delete(uRoom, c) - userRemaining += len(uRoom) if len(uRoom) == 0 { delete(h.userRooms, c.userID) } } - if sseRoom, ok := h.sseUserRooms[c.userID]; ok { - userRemaining += len(sseRoom) - } } c.closeSend() - - // 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, "doc_id": docID}) - } - - // Broadcast awareness leave for doc rooms - for _, docID := range docRoomsLeft { - h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{ - "docId": docID, - "userId": c.userID, - }, nil) - } } func (h *Hub) RegisterSSE(c *SSEClient) { @@ -294,7 +210,6 @@ func (h *Hub) UnregisterSSE(c *SSEClient) { } } } - var docRoomsLeft []string if c.subscribedRooms != nil { for rID := range c.subscribedRooms { if room, ok := h.sseRooms[rID]; ok { @@ -303,48 +218,18 @@ func (h *Hub) UnregisterSSE(c *SSEClient) { delete(h.sseRooms, rID) } } - if strings.HasPrefix(rID, "doc:") { - docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:")) - } } } - userRemaining := 0 if c.userID != "" && c.userID != "anonymous" { if uRoom, ok := h.sseUserRooms[c.userID]; ok { delete(uRoom, c) - userRemaining += len(uRoom) if len(uRoom) == 0 { delete(h.sseUserRooms, c.userID) } } - if wsRoom, ok := h.userRooms[c.userID]; ok { - userRemaining += len(wsRoom) - } } c.closeSend() - - 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() - - for _, docID := range releasedDocIDs { - h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) - } - - for _, docID := range docRoomsLeft { - h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{ - "docId": docID, - "userId": c.userID, - }, nil) - } } func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool { @@ -386,354 +271,9 @@ func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool { return false } -// 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, isAdmin bool) bool { - h.mu.Lock() - defer h.mu.Unlock() - - item, ok := h.locks[docID] - if !ok { - return false - } - if item.userID == userID || isAdmin || 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), - } -} - -// Block Locking Methods (Option B) - -func (h *Hub) AcquireBlockLock(docID, blockID, userID, userName, userColor string, ttl time.Duration) (*domain.BlockLockInfo, bool, *domain.BlockLockInfo) { - h.mu.Lock() - defer h.mu.Unlock() - - now := time.Now() - key := docID + ":" + blockID - existing, exists := h.blockLocks[key] - if exists && existing.expiresAt.After(now) { - if existing.userID != userID { - info := &domain.BlockLockInfo{ - DocID: existing.docID, - BlockID: existing.blockID, - UserID: existing.userID, - UserName: existing.userName, - UserColor: existing.userColor, - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return nil, false, info - } - // Refresh lease - existing.expiresAt = now.Add(ttl) - if userName != "" { - existing.userName = userName - } - if userColor != "" { - existing.userColor = userColor - } - info := &domain.BlockLockInfo{ - DocID: existing.docID, - BlockID: existing.blockID, - UserID: existing.userID, - UserName: existing.userName, - UserColor: existing.userColor, - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return info, true, nil - } - - item := &blockLockItem{ - docID: docID, - blockID: blockID, - userID: userID, - userName: userName, - userColor: userColor, - lockedAt: now, - expiresAt: now.Add(ttl), - } - h.blockLocks[key] = item - info := &domain.BlockLockInfo{ - DocID: item.docID, - BlockID: item.blockID, - UserID: item.userID, - UserName: item.userName, - UserColor: item.userColor, - ExpiresAt: item.expiresAt.Format(time.RFC3339), - } - return info, true, nil -} - -func (h *Hub) ReleaseBlockLock(docID, blockID, userID string) bool { - h.mu.Lock() - defer h.mu.Unlock() - - key := docID + ":" + blockID - existing, exists := h.blockLocks[key] - if !exists { - return false - } - if userID != "" && existing.userID != userID && userID != "admin" { - return false - } - delete(h.blockLocks, key) - return true -} - -func (h *Hub) GetDocBlockLocks(docID string) []domain.BlockLockInfo { - h.mu.RLock() - defer h.mu.RUnlock() - - now := time.Now() - var result []domain.BlockLockInfo - prefix := docID + ":" - for k, l := range h.blockLocks { - if strings.HasPrefix(k, prefix) && l.expiresAt.After(now) { - result = append(result, domain.BlockLockInfo{ - DocID: l.docID, - BlockID: l.blockID, - UserID: l.userID, - UserName: l.userName, - UserColor: l.userColor, - ExpiresAt: l.expiresAt.Format(time.RFC3339), - }) - } - } - return result -} - -// Live Session Methods (Opt-In Live Collaboration) - -func (h *Hub) StartLiveSession(docID, userID, userName string, ttl time.Duration) (*domain.LiveSessionInfo, bool, *domain.LiveSessionInfo) { - h.mu.Lock() - defer h.mu.Unlock() - - now := time.Now() - existing, exists := h.liveSessions[docID] - if exists && existing.expiresAt.After(now) { - if existing.initiatorID != userID { - info := &domain.LiveSessionInfo{ - DocID: existing.docID, - InitiatorID: existing.initiatorID, - InitiatorName: existing.initiatorName, - StartedAt: existing.startedAt.Format(time.RFC3339), - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return nil, false, info - } - // Refresh lease - existing.expiresAt = now.Add(ttl) - info := &domain.LiveSessionInfo{ - DocID: existing.docID, - InitiatorID: existing.initiatorID, - InitiatorName: existing.initiatorName, - StartedAt: existing.startedAt.Format(time.RFC3339), - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return info, true, nil - } - - item := &liveSessionItem{ - docID: docID, - initiatorID: userID, - initiatorName: userName, - startedAt: now, - expiresAt: now.Add(ttl), - } - h.liveSessions[docID] = item - info := &domain.LiveSessionInfo{ - DocID: item.docID, - InitiatorID: item.initiatorID, - InitiatorName: item.initiatorName, - StartedAt: item.startedAt.Format(time.RFC3339), - ExpiresAt: item.expiresAt.Format(time.RFC3339), - } - return info, true, nil -} - -func (h *Hub) EndLiveSession(docID, userID string) bool { - h.mu.Lock() - defer h.mu.Unlock() - - existing, exists := h.liveSessions[docID] - if !exists { - return false - } - if userID != "" && existing.initiatorID != userID && userID != "admin" { - return false - } - delete(h.liveSessions, docID) - return true -} - -func (h *Hub) ForceEndLiveSession(docID string) bool { - h.mu.Lock() - defer h.mu.Unlock() - - if _, exists := h.liveSessions[docID]; !exists { - return false - } - delete(h.liveSessions, docID) - return true -} - -func (h *Hub) GetLiveSession(docID string) *domain.LiveSessionInfo { - h.mu.RLock() - defer h.mu.RUnlock() - - now := time.Now() - existing, exists := h.liveSessions[docID] - if !exists || existing.expiresAt.Before(now) { - return nil - } - return &domain.LiveSessionInfo{ - DocID: existing.docID, - InitiatorID: existing.initiatorID, - InitiatorName: existing.initiatorName, - StartedAt: existing.startedAt.Format(time.RFC3339), - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } -} - -func (h *Hub) cleanupLocksLoop() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-h.stopCleanup: - return - case <-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) - } - } - - type expBlock struct { - docID string - blockID string - } - var expiredBlocks []expBlock - for k, bl := range h.blockLocks { - if bl.expiresAt.Before(now) { - expiredBlocks = append(expiredBlocks, expBlock{docID: bl.docID, blockID: bl.blockID}) - delete(h.blockLocks, k) - } - } - - var expiredLiveSessions []string - for docID, ls := range h.liveSessions { - if ls.expiresAt.Before(now) { - expiredLiveSessions = append(expiredLiveSessions, docID) - delete(h.liveSessions, docID) - } - } - h.mu.Unlock() - - for _, docID := range expiredDocIDs { - h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) - } - for _, b := range expiredBlocks { - h.BroadcastRoom("doc:"+b.docID, "DOC_BLOCK_UNLOCKED", map[string]string{ - "docId": b.docID, - "blockId": b.blockID, - }, nil) - } - for _, docID := range expiredLiveSessions { - h.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", map[string]string{ - "docId": docID, - }, nil) - } - } - } -} - -// Close gracefully shuts down the Hub, stops background tickers, flushes dirty document rooms to DB, -// and immediately disconnects all WebSocket and SSE connections so HTTP shutdown completes in milliseconds. +// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately. func (h *Hub) Close() { h.mu.Lock() if h.closed { @@ -742,19 +282,6 @@ func (h *Hub) Close() { } h.closed = true - if h.stopCleanup != nil { - select { - case <-h.stopCleanup: - default: - close(h.stopCleanup) - } - } - - docRooms := make([]*DocRoom, 0, len(h.docRooms)) - for _, r := range h.docRooms { - docRooms = append(docRooms, r) - } - allWsClients := make([]*Client, 0, len(h.allClients)) for c := range h.allClients { allWsClients = append(allWsClients, c) @@ -766,28 +293,11 @@ func (h *Hub) Close() { } h.mu.Unlock() - // 1. Flush any active dirty document rooms and close doc WS connections - for _, r := range docRooms { - r.Flush() - r.mu.Lock() - if r.debounceTimer != nil { - r.debounceTimer.Stop() - r.debounceTimer = nil - } - for c := range r.wsClients { - _ = c.conn.Close() - } - r.wsClients = make(map[*DocWSClient]bool) - r.mu.Unlock() - } - - // 2. Close all WebSocket connections for _, c := range allWsClients { _ = c.conn.Close() c.closeSend() } - // 3. Close all SSE client channels so ServeSSE select loop exits immediately for _, c := range allSSEClients { c.closeSend() } @@ -1116,21 +626,6 @@ func (c *Client) readPump() { c.hub.LeaveRoom(c, room) } } - - 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, false) { - c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) - } - } - } } } }