From 0e06cc5577c30359c5bf53524976b797897e0052 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 14:02:38 +0100 Subject: [PATCH] feat(collab): redesign note collaboration with REST auto-save, opt-in live session, and block locking --- .../src/features/workspace/DocumentEditor.tsx | 435 ++++++++++++++++-- .../src/features/workspace/OutlinerBlock.tsx | 34 +- .../src/features/workspace/OutlinerTree.tsx | 82 ++-- .../src/features/workspace/WorkspaceView.tsx | 222 +++++++-- frontend/src/lib/api.ts | 36 ++ frontend/src/lib/types.ts | 22 + frontend/src/lib/useDocumentSync.ts | 206 ++++----- internal/api/server.go | 196 ++++++++ internal/api/server_test.go | 121 +++++ internal/domain/models.go | 23 + internal/ws/hub.go | 235 ++++++++++ 11 files changed, 1373 insertions(+), 239 deletions(-) diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 80d9f44..6f81208 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -15,11 +15,14 @@ import { Sparkles, Plus, } from 'lucide-react'; -import { Document, DocumentBlock, User } from '../../lib/types'; -import { api } from '../../lib/api'; -import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync'; +import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types'; +import { api, getToken, onWebSocketMessage } from '../../lib/api'; +import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync'; 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; currentUser?: User | null; @@ -34,6 +37,9 @@ export interface DocumentEditorProps { 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 = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒']; @@ -154,6 +160,9 @@ export const DocumentEditor: React.FC = ({ onWikiLinkClick, onBackMobile, onSyncStatusChange, + onSaveStatusChange, + onLiveStateChange, + registerLiveToggle, }) => { const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); @@ -165,11 +174,29 @@ 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'); + const [, setSaveErrorMessage] = useState(null); + const saveTimeoutRef = useRef(null); + const isSavingRef = useRef(false); + const lastSavedSnapshotRef = useRef(''); + const isInitialLoadRef = useRef(true); + const titleInputRef = useRef(null); const titleDebounceRef = useRef(null); const docRef = useRef(doc); docRef.current = doc; + const currentTitleRef = useRef(title); + currentTitleRef.current = title; + const currentBlocksRef = useRef(blocks); + currentBlocksRef.current = blocks; + useEffect(() => { if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { const focusTitle = () => { @@ -209,6 +236,10 @@ export const DocumentEditor: React.FC = ({ return doc.permission === 'owner' || doc.permission === 'write'; }, [doc]); + // Is this document completely locked because another user has an active live session? + const isLockedByOtherLive = Boolean(liveSession && liveSession.initiatorId !== currentUser?.id); + const effectiveCanEdit = canEdit && !isLockedByOtherLive; + // Handle incoming WebSocket events for THIS document room const handleRemoteEvent = useCallback( (evt: DocSyncEvent) => { @@ -334,6 +365,137 @@ export const DocumentEditor: React.FC = ({ [docId, onDocMetadataChange] ); + // Extract all #tags present across the current blocks + const extractedTags = useMemo(() => { + const tagSet = new Set(); + for (const b of blocks) { + const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); + if (matches) { + for (const m of matches) { + tagSet.add(m.slice(1).toLowerCase()); + } + } + } + return Array.from(tagSet); + }, [blocks]); + + const currentTagsRef = useRef(extractedTags); + currentTagsRef.current = extractedTags; + + // AutoSave implementation (authoritative persistence via HTTP REST) + const currentSnapshot = useMemo(() => { + return JSON.stringify({ + title, + blocks, + tags: extractedTags, + }); + }, [title, blocks, extractedTags]); + + const currentSnapshotRef = useRef(currentSnapshot); + currentSnapshotRef.current = currentSnapshot; + + const executeAutoSave = useCallback( + async (immediate: boolean = false) => { + if (!docId || !canEdit || isLockedByOtherLive) return; + if (isSavingRef.current && !immediate) return; + + const snapshot = currentSnapshotRef.current; + if (snapshot === lastSavedSnapshotRef.current) { + setSaveStatus('saved'); + onSaveStatusChange?.('saved'); + return; + } + + isSavingRef.current = true; + setSaveStatus('saving'); + onSaveStatusChange?.('saving'); + + try { + const updated = await api.updateDocument(docId, { + title: currentTitleRef.current, + blocks: currentBlocksRef.current, + tags: currentTagsRef.current, + }); + isSavingRef.current = false; + lastSavedSnapshotRef.current = snapshot; + setSaveStatus('saved'); + setSaveErrorMessage(null); + onSaveStatusChange?.('saved'); + if (docRef.current) { + docRef.current = updated; + onDocMetadataChange(updated); + } + } catch (err: any) { + isSavingRef.current = false; + const msg = err?.message || 'Fehler beim Speichern'; + setSaveStatus('error'); + setSaveErrorMessage(msg); + onSaveStatusChange?.('error', msg); + } + }, + [docId, canEdit, isLockedByOtherLive, onSaveStatusChange, onDocMetadataChange] + ); + + useEffect(() => { + if (isInitialLoadRef.current) return; + if (currentSnapshot === lastSavedSnapshotRef.current) return; + + setSaveStatus('unsaved'); + onSaveStatusChange?.('unsaved'); + + if (saveTimeoutRef.current !== null) { + window.clearTimeout(saveTimeoutRef.current); + } + saveTimeoutRef.current = window.setTimeout(() => { + saveTimeoutRef.current = null; + executeAutoSave(); + }, 800); + + return () => { + if (saveTimeoutRef.current !== null) { + window.clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + }; + }, [currentSnapshot, executeAutoSave, onSaveStatusChange]); + + // Flush on page unload / pagehide via keepalive fetch + useEffect(() => { + const handleUnload = () => { + if (saveTimeoutRef.current !== null) { + window.clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + const snapshot = currentSnapshotRef.current; + if (snapshot !== lastSavedSnapshotRef.current && canEdit && !isLockedByOtherLive) { + const token = getToken(); + if (token) { + fetch(`/api/v1/documents/${docId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + title: currentTitleRef.current, + blocks: currentBlocksRef.current, + tags: currentTagsRef.current, + }), + keepalive: true, + }).catch(() => {}); + } + } + }; + + window.addEventListener('beforeunload', handleUnload); + window.addEventListener('pagehide', handleUnload); + return () => { + window.removeEventListener('beforeunload', handleUnload); + window.removeEventListener('pagehide', handleUnload); + handleUnload(); + }; + }, [docId, canEdit, isLockedByOtherLive]); + const { status: syncStatus, remoteCursors, @@ -343,12 +505,158 @@ export const DocumentEditor: React.FC = ({ sendBlockDeleted, sendBlockMoved, sendTitleUpdated, - } = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser); + } = 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 handleToggleLive = useCallback(() => { + if (isLiveEnabled) { + api.endLiveSession(docId).catch(() => {}); + setIsLiveEnabled(false); + setLiveSession(null); + } else { + if (isLockedByOtherLive) return; + api.startLiveSession(docId) + .then((session) => { + setLiveSession(session); + setIsLiveEnabled(true); + }) + .catch((err) => { + console.error('[DocumentEditor] Failed to start live session:', err); + }); + } + }, [docId, isLiveEnabled, isLockedByOtherLive]); + + useEffect(() => { + if (registerLiveToggle) { + registerLiveToggle(handleToggleLive); + } + }, [registerLiveToggle, handleToggleLive]); + + // Load initial locks on mount + useEffect(() => { + let cancelled = false; + api.getDocLocks(docId) + .then((res) => { + if (cancelled) return; + if (res.liveSession) { + setLiveSession(res.liveSession); + if (res.liveSession.initiatorId === currentUser?.id) { + setIsLiveEnabled(true); + } + } + if (Array.isArray(res.blockLocks)) { + const map: Record = {}; + for (const l of res.blockLocks) { + map[l.blockId] = l; + } + setActiveBlockLocks(map); + } + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [docId, currentUser?.id]); + + // Subscribe to realtime lock & live session events + useEffect(() => { + const unsub = onWebSocketMessage((msg: WSMessage) => { + if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) { + setActiveBlockLocks((prev) => ({ + ...prev, + [msg.payload.blockId]: msg.payload, + })); + } else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) { + setActiveBlockLocks((prev) => { + const next = { ...prev }; + delete next[msg.payload.blockId]; + return next; + }); + } else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) { + setLiveSession(msg.payload); + } else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) { + setLiveSession(null); + if (liveSession?.initiatorId === currentUser?.id) { + setIsLiveEnabled(false); + } + } + }); + + return () => { + unsub(); + }; + }, [docId, currentUser?.id, liveSession?.initiatorId]); + + // 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; + api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {}); + + 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) => { + if (lockKeepaliveTimerRef.current !== null) { + window.clearInterval(lockKeepaliveTimerRef.current); + lockKeepaliveTimerRef.current = null; + } + if (focusedBlockIdRef.current === blockId) { + focusedBlockIdRef.current = null; + } + api.unlockBlock(docId, blockId).catch(() => {}); + executeAutoSave(); + }, + [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 useEffect(() => { let isCancelled = false; @@ -361,11 +669,31 @@ export const DocumentEditor: React.FC = ({ .getDocument(docId) .then((fetched) => { if (isCancelled) return; + const normalized = ensureNonEmptyBlocks(docId, fetched.blocks); setDoc(fetched); setTitle(fetched.title || 'Unbenannte Notiz'); - setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks)); + setBlocks(normalized); setLoading(false); onDocMetadataChange(fetched); + + const initialTags: string[] = []; + for (const b of normalized) { + const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); + if (matches) { + for (const m of matches) { + const tag = m.slice(1).toLowerCase(); + if (!initialTags.includes(tag)) initialTags.push(tag); + } + } + } + lastSavedSnapshotRef.current = JSON.stringify({ + title: fetched.title || 'Unbenannte Notiz', + blocks: normalized, + tags: initialTags, + }); + isInitialLoadRef.current = false; + setSaveStatus('saved'); + onSaveStatusChange?.('saved'); }) .catch((err: any) => { if (isCancelled) return; @@ -382,20 +710,6 @@ export const DocumentEditor: React.FC = ({ }; }, [docId]); - // Extract all #tags present across the current blocks - const extractedTags = useMemo(() => { - const tagSet = new Set(); - for (const b of blocks) { - const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); - if (matches) { - for (const m of matches) { - tagSet.add(m.slice(1).toLowerCase()); - } - } - } - return Array.from(tagSet); - }, [blocks]); - // Keep parent sidebar metadata in sync when blocks/tags change useEffect(() => { if (!docRef.current) return; @@ -425,7 +739,7 @@ export const DocumentEditor: React.FC = ({ }, [remoteCursors]); const handleTitleChange = (e: React.ChangeEvent) => { - if (!canEdit) return; + if (!effectiveCanEdit) return; const nextTitle = e.target.value; setTitle(nextTitle); @@ -434,7 +748,9 @@ export const DocumentEditor: React.FC = ({ } titleDebounceRef.current = window.setTimeout(() => { titleDebounceRef.current = null; - sendTitleUpdated(nextTitle); + if (isLiveEnabled) { + sendTitleUpdated(nextTitle); + } }, 250); }; @@ -442,8 +758,11 @@ export const DocumentEditor: React.FC = ({ if (titleDebounceRef.current !== null) { window.clearTimeout(titleDebounceRef.current); titleDebounceRef.current = null; - sendTitleUpdated(title); + if (isLiveEnabled) { + sendTitleUpdated(title); + } } + executeAutoSave(); }; // Flush title debouncing on window blur so background window never holds stale pending title @@ -452,25 +771,31 @@ export const DocumentEditor: React.FC = ({ if (titleDebounceRef.current !== null) { window.clearTimeout(titleDebounceRef.current); titleDebounceRef.current = null; - sendTitleUpdated(title); + if (isLiveEnabled) { + sendTitleUpdated(title); + } } }; window.addEventListener('blur', handleWindowBlur); return () => window.removeEventListener('blur', handleWindowBlur); - }, [title, sendTitleUpdated]); + }, [title, isLiveEnabled, sendTitleUpdated]); const handleUpdateBlock = useCallback( (updatedBlock: DocumentBlock) => { + if (!effectiveCanEdit) return; setBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)) ); - sendBlockUpdated(updatedBlock); + if (isLiveEnabled) { + sendBlockUpdated(updatedBlock); + } }, - [sendBlockUpdated] + [effectiveCanEdit, isLiveEnabled, sendBlockUpdated] ); const handleCreateBlock = useCallback( (newBlock: DocumentBlock, afterBlockId?: string) => { + if (!effectiveCanEdit) return; setBlocks((prev) => { const next = [...prev]; if (afterBlockId) { @@ -478,33 +803,41 @@ export const DocumentEditor: React.FC = ({ if (idx >= 0) { next.splice(idx + 1, 0, newBlock); const normalized = ensureNonEmptyBlocks(docId, next); - sendBlockCreated(newBlock, afterBlockId, normalized); + if (isLiveEnabled) { + sendBlockCreated(newBlock, afterBlockId, normalized); + } return normalized; } } next.push(newBlock); const normalized = ensureNonEmptyBlocks(docId, next); - sendBlockCreated(newBlock, afterBlockId, normalized); + if (isLiveEnabled) { + sendBlockCreated(newBlock, afterBlockId, normalized); + } return normalized; }); }, - [docId, sendBlockCreated] + [docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated] ); const handleDeleteBlock = useCallback( (blockId: string) => { + if (!effectiveCanEdit) return; setBlocks((prev) => { const filtered = prev.filter((b) => b.id !== blockId); const normalized = ensureNonEmptyBlocks(docId, filtered); - sendBlockDeleted(blockId, normalized); + if (isLiveEnabled) { + sendBlockDeleted(blockId, normalized); + } return normalized; }); }, - [docId, sendBlockDeleted] + [docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted] ); const handleMoveBlock = useCallback( (blockId: string, newIndex: number, level?: number) => { + if (!effectiveCanEdit) return; setBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === blockId); if (fromIdx === -1) return prev; @@ -517,11 +850,13 @@ export const DocumentEditor: React.FC = ({ const boundedIdx = Math.max(0, Math.min(newIndex, copy.length)); copy.splice(boundedIdx, 0, updatedMoving); const normalized = ensureNonEmptyBlocks(docId, copy); - sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized); + if (isLiveEnabled) { + sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized); + } return normalized; }); }, - [docId, sendBlockMoved] + [docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved] ); const handleTogglePin = async () => { @@ -537,7 +872,7 @@ export const DocumentEditor: React.FC = ({ const handleSelectIcon = async (icon: string) => { setShowEmojiPicker(false); - if (!doc || !canEdit) return; + if (!doc || !effectiveCanEdit) return; try { const updated = await api.updateDocument(doc.id, { icon }); setDoc(updated); @@ -790,6 +1125,16 @@ 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. + +
+ )} + {/* Main Content Area */}
{zoomedBlock ? ( @@ -830,7 +1175,7 @@ export const DocumentEditor: React.FC = ({ } }} placeholder="Ast-Titel…" - readOnly={!canEdit} + readOnly={!effectiveCanEdit} 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" />
@@ -850,7 +1195,7 @@ export const DocumentEditor: React.FC = ({ {childBlocks.length === 0 ? (

Dieser Ast hat noch keine Unterpunkte.

- {canEdit && ( + {effectiveCanEdit && (
@@ -900,13 +1249,13 @@ export const DocumentEditor: React.FC = ({
- {showEmojiPicker && canEdit && ( + {showEmojiPicker && effectiveCanEdit && (
{EMOJI_PALETTE.map((emoji) => (
- {/* Right Header Section: Live Sync Status (identical to SharedListsView) */} + {/* Right Header Section: Save Status & Live Collaboration Button */}
- {(() => { - const isDocActive = subView === 'editor' && Boolean(selectedDocId); - const isConnected = isDocActive - ? activeDocSyncStatus === 'connected' - : realtimeConnected; - const isConnecting = isDocActive && (activeDocSyncStatus === 'connecting' || activeDocSyncStatus === 'reconnecting'); - const isSSE = !isDocActive && realtimeTransport === 'sse'; - - return ( + {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'} +
+ ) : docLiveState === 'blocked' ? ( + + ) : docLiveState === 'connecting' ? ( + + ) : docLiveState === 'active' ? ( + + ) : ( + + )} + + ) : ( + /* Standalone Realtime Status Indicator for Todos / Hashtags */ + (() => { + const isConnected = realtimeConnected; + const isSSE = realtimeTransport === 'sse'; + return ( +
+ +
+ ); + })() + )}
@@ -1414,6 +1515,17 @@ export const WorkspaceView: React.FC = ({ 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' ? ( /* ========================================================================= @@ -2403,6 +2515,50 @@ 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 6c258e0..2fb032a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,6 +6,9 @@ import { DocumentBlock, DocShare, DocLockInfo, + BlockLockInfo, + LiveSessionInfo, + DocLocksResponse, KanbanColumn, ListItem, ListShare, @@ -625,6 +628,39 @@ 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 { + return request(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, { + method: 'POST', + body: JSON.stringify({ userColor, ttlSeconds }), + }); + }, + + async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> { + return request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, { + method: 'POST', + }); + }, + + async startLiveSession(docId: string): Promise { + return request(`/api/v1/documents/${docId}/live/start`, { + method: 'POST', + }); + }, + + 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 ea44b68..7a5e479 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -207,6 +207,28 @@ export interface DocLockInfo { 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 index b27b57f..7d70d89 100644 --- a/frontend/src/lib/useDocumentSync.ts +++ b/frontend/src/lib/useDocumentSync.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { DocumentBlock } from './types'; import { getToken, api } from './api'; -export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline'; +export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked'; export interface RemoteCursor { clientId: string; @@ -73,9 +73,10 @@ export function useDocumentSync( docId: string | null, onRemoteEvent: (evt: DocSyncEvent) => void, canEdit: boolean = true, - currentUser?: { id: string; email?: string; displayName?: string } | null + currentUser?: { id: string; email?: string; displayName?: string } | null, + enabled: boolean = false ) { - const [status, setStatus] = useState('connecting'); + const [status, setStatus] = useState(enabled ? 'connecting' : 'idle'); const [remoteCursors, setRemoteCursors] = useState>({}); const wsRef = useRef(null); const clientIdRef = useRef( @@ -136,10 +137,16 @@ export function useDocumentSync( }, []); useEffect(() => { - if (!docId) { - setStatus('offline'); + if (!docId || !enabled) { + setStatus('idle'); setRemoteCursors({}); pendingMessagesQueueRef.current = []; + if (wsRef.current) { + try { + wsRef.current.close(); + } catch {} + wsRef.current = null; + } return; } @@ -173,13 +180,24 @@ export function useDocumentSync( try { ws = new WebSocket(wsUrl); } catch { - setStatus('offline'); + 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; @@ -266,12 +284,17 @@ export function useDocumentSync( }; ws.onclose = () => { + window.clearTimeout(handshakeTimer); if (isDisposed) return; wsRef.current = null; retryCount += 1; - setStatus(retryCount > 2 ? 'offline' : 'reconnecting'); - const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000); - reconnectTimerRef.current = window.setTimeout(connect, delay); + 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); + } }; }; @@ -304,7 +327,7 @@ export function useDocumentSync( wsRef.current = null; } }; - }, [docId, localUserName, localUserColor]); + }, [docId, enabled, localUserName, localUserColor]); const executeSendCursor = useCallback( (blockId: string | null, selectionStart?: number, selectionEnd?: number) => { @@ -377,165 +400,90 @@ export function useDocumentSync( ); const sendRawEvent = useCallback( - (evt: DocSyncEvent, fallbackFn?: () => void) => { + (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, }; - const jsonStr = JSON.stringify(payload); - - if (ws && ws.readyState === WebSocket.OPEN) { - try { - ws.send(jsonStr); - } catch (err) { - console.warn('[DocSync] Failed to send WebSocket message, buffering:', err); - pendingMessagesQueueRef.current.push(jsonStr); - } - } else { - console.warn( - `[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`, - evt.type - ); - pendingMessagesQueueRef.current.push(jsonStr); - - if (fallbackFn) { - if (fallbackTimerRef.current !== null) { - window.clearTimeout(fallbackTimerRef.current); - } - fallbackTimerRef.current = window.setTimeout(() => { - fallbackFn(); - }, 600); - } + 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, - }, - () => { - if (docId && allBlocks) { - api - .patchDocumentBlocks(docId, { - upserted: [block], - blockOrder: allBlocks.map((b) => b.id), - }) - .catch(() => {}); - } - } - ); + (block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => { + sendRawEvent({ + type: 'block_created', + block, + afterBlockId, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendBlockUpdated = useCallback( (block: DocumentBlock) => { - sendRawEvent( - { - type: 'block_updated', - blockId: block.id, - block, - }, - () => { - if (docId) { - api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {}); - } - } - ); + sendRawEvent({ + type: 'block_updated', + blockId: block.id, + block, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendBlockDeleted = useCallback( - (blockId: string, allBlocks?: DocumentBlock[]) => { - sendRawEvent( - { - type: 'block_deleted', - blockId, - }, - () => { - if (docId) { - api - .patchDocumentBlocks(docId, { - deletedIds: [blockId], - blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined, - }) - .catch(() => {}); - } - } - ); + (blockId: string, _allBlocks?: DocumentBlock[]) => { + sendRawEvent({ + type: 'block_deleted', + blockId, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendBlockMoved = useCallback( - (blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => { - sendRawEvent( - { - type: 'block_moved', - blockId, - newIndex, - level, - }, - () => { - if (docId && allBlocks) { - api - .patchDocumentBlocks(docId, { - upserted: allBlocks, - blockOrder: allBlocks.map((b) => b.id), - }) - .catch(() => {}); - } - } - ); + (blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => { + sendRawEvent({ + type: 'block_moved', + blockId, + newIndex, + level, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendTitleUpdated = useCallback( (title: string) => { - sendRawEvent( - { - type: 'title_updated', - title, - }, - () => { - if (docId) { - api.updateDocument(docId, { title }).catch(() => {}); - } - } - ); + sendRawEvent({ + type: 'title_updated', + title, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendBlocksReplaced = useCallback( (blocks: DocumentBlock[]) => { - sendRawEvent( - { - type: 'blocks_replaced', - blocks, - }, - () => { - if (docId) { - api.updateDocument(docId, { blocks }).catch(() => {}); - } - } - ); + sendRawEvent({ + type: 'blocks_replaced', + blocks, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); return { diff --git a/internal/api/server.go b/internal/api/server.go index 78645a5..8b87870 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -145,6 +145,13 @@ 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) @@ -923,6 +930,19 @@ 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"` @@ -954,6 +974,19 @@ 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") @@ -1381,6 +1414,169 @@ 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) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "lock": lockInfo, + }) + 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 { + s.wsHub.ReleaseBlockLock(docID, blockID, userID) + s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_UNLOCKED", map[string]string{ + "docId": docID, + "blockId": blockID, + "userId": userID, + }, nil) + } + 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 := 120 * time.Second // 2 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, + }) + 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") + + if s.wsHub != nil { + 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 { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f4642cc..5bb19cb 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1295,6 +1295,127 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) { } } +func TestBlockLockingAndLiveSessionGuards(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + + userA, _ := store.CreateUser("user_a_lock@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") + tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion) + + // Create document owned by User A, shared with User B + 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) + 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) + 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) + } + + // 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}`))) + 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) + } + + // 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! + patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{ + Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}}, + }) + 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.StatusLocked { + t.Fatalf("expected 423 Locked for User B while live session is active, got %d, body: %s", rec.Code, rec.Body.String()) + } + + // 8. User A ends the Live-Session + req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/end", nil) + 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()) + } +} + + diff --git a/internal/domain/models.go b/internal/domain/models.go index c2241f7..46c1934 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -344,6 +344,29 @@ type DocLockInfo struct { 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/ws/hub.go b/internal/ws/hub.go index 3b6d737..5936de8 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -92,6 +92,24 @@ type docLockItem struct { 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 @@ -106,6 +124,8 @@ type Hub struct { 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{} @@ -121,6 +141,8 @@ func NewHub() *Hub { 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{}), } @@ -459,6 +481,187 @@ func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo { } } +// 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) 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() @@ -470,6 +673,7 @@ func (h *Hub) cleanupLocksLoop() { case <-ticker.C: h.mu.Lock() now := time.Now() + var expiredDocIDs []string for docID, l := range h.locks { if l.expiresAt.Before(now) { @@ -477,11 +681,42 @@ func (h *Hub) cleanupLocksLoop() { 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) + } } } }