feat(workspace): per-user note pinning, real-time unlock synchronization, and icon-only share button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s

This commit is contained in:
sandrodach committed 2026-10-25 10:39:52 +01:00
1 parent ec79a2493e
commit af7a9eb200
5 files changed
+182 -46

No files matched your search

@@ -43,6 +43,7 @@ import {
Lock, Lock,
Unlock, Unlock,
Eye, Eye,
Share,
Share2, Share2,
} from 'lucide-react'; } from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types'; import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types';
@@ -373,15 +374,28 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}; };
}, [selectedDoc?.id]); }, [selectedDoc?.id]);
// Flush pending save before tab closes or unmounts // Flush pending save and release lock before tab closes or unmounts
useEffect(() => { useEffect(() => {
const handleBeforeUnload = () => { const handleBeforeUnload = () => {
flushPendingSave(); flushPendingSave();
if (currentDocIdRef.current && holdsLockRef.current) {
try {
const token = localStorage.getItem('mindsift_token') || sessionStorage.getItem('mindsift_token');
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
keepalive: true,
}).catch(() => {});
} catch {}
}
}; };
window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('beforeunload', handleBeforeUnload);
return () => { return () => {
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
flushPendingSave(); flushPendingSave();
if (currentDocIdRef.current && holdsLockRef.current) {
releaseMyLock(currentDocIdRef.current);
}
}; };
}, []); }, []);
@@ -399,8 +413,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
} }
} else if (msg.event === 'DOC_UNLOCKED') { } else if (msg.event === 'DOC_UNLOCKED') {
const payload = msg.payload as { doc_id: string }; const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
if (payload && payload.doc_id === selectedDoc?.id) { const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
if (unlockedDocId && unlockedDocId === selectedDoc?.id) {
setActiveLock(null); setActiveLock(null);
} }
} else if (msg.event === 'DOC_CREATED') { } else if (msg.event === 'DOC_CREATED') {
@@ -413,11 +428,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const updated = msg.payload as Document; const updated = msg.payload as Document;
if (updated && updated.id) { if (updated && updated.id) {
setDocs((prev) => setDocs((prev) =>
prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)) prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d))
); );
if (selectedDoc?.id === updated.id) { if (selectedDoc?.id === updated.id) {
if (!holdsLockRef.current) { if (!holdsLockRef.current) {
setSelectedDoc((prev) => (prev ? { ...prev, ...updated } : updated)); setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated));
} }
} }
onStatsChange(); onStatsChange();
@@ -1915,16 +1930,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<button <button
type="button" type="button"
onClick={openShareModal} onClick={openShareModal}
className="btn-icon flex items-center gap-1 px-2 text-xs" className="btn-icon"
title="Notiz teilen und Berechtigungen" title="Notiz teilen und Berechtigungen"
> >
<Share2 className="w-3.5 h-3.5" /> <Share className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Teilen</span>
{selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && (
<span className="ml-0.5 px-1 rounded-full bg-accent/20 text-accent text-[10px] font-bold">
{selectedDoc.sharedWith.length}
</span>
)}
</button> </button>
<button <button
+3 -1
View File
@@ -792,7 +792,9 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
}
writeJSON(w, http.StatusOK, doc) writeJSON(w, http.StatusOK, doc)
} }
@@ -927,7 +929,7 @@ func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id") docID := chi.URLParam(r, "id")
released := s.wsHub.ReleaseDocLock(docID, userID) released := s.wsHub.ReleaseDocLock(docID, userID)
if released { if released {
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID}) s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
} }
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released}) writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
} }
+78 -29
View File
@@ -215,6 +215,20 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id); CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);
CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id); CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);
CREATE TABLE IF NOT EXISTS doc_pins (
user_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(user_id, doc_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_doc_pins_user ON doc_pins(user_id);
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
CREATE TABLE IF NOT EXISTS lists ( CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
space_id TEXT NOT NULL, space_id TEXT NOT NULL,
@@ -1153,6 +1167,9 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
if err != nil { if err != nil {
return nil, err return nil, err
} }
if isPinned {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt)
}
s.saveDocumentToDisk(doc) s.saveDocumentToDisk(doc)
return doc, nil return doc, nil
} }
@@ -1180,12 +1197,16 @@ func scanDocument(rows interface {
} }
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
// 1. Fetch user's own documents // 1. Fetch user's own documents (with per-user is_pinned from doc_pins)
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
FROM documents WHERE user_id = ? AND is_archived = 0 CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
ORDER BY is_pinned DESC, updated_at DESC`, d.is_archived, d.created_at, d.updated_at
userID, FROM documents d
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.user_id = ? AND d.is_archived = 0
ORDER BY is_pinned DESC, d.updated_at DESC`,
userID, userID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1202,16 +1223,19 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
} }
_ = rows.Close() _ = rows.Close()
// 2. Fetch documents shared with the user // 2. Fetch documents shared with the user (with per-user is_pinned from doc_pins)
sharedRows, err := s.db.Query( sharedRows, err := s.db.Query(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, d.is_pinned, d.is_archived, d.created_at, d.updated_at, `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
s.permission, u.display_name s.permission, u.display_name
FROM documents d FROM documents d
JOIN doc_shares s ON d.id = s.doc_id JOIN doc_shares s ON d.id = s.doc_id
JOIN users u ON d.user_id = u.id JOIN users u ON d.user_id = u.id
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE s.user_id = ? AND d.is_archived = 0 WHERE s.user_id = ? AND d.is_archived = 0
ORDER BY d.updated_at DESC`, ORDER BY is_pinned DESC, d.updated_at DESC`,
userID, userID, userID,
) )
if err == nil { if err == nil {
for sharedRows.Next() { for sharedRows.Next() {
@@ -1252,11 +1276,15 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
} }
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) { func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
// Try owned document first // Try owned document first (with per-user is_pinned)
row := s.db.QueryRow( row := s.db.QueryRow(
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
FROM documents WHERE id = ? AND user_id = ?`, CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
docID, userID, d.is_archived, d.created_at, d.updated_at
FROM documents d
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.id = ? AND d.user_id = ?`,
userID, docID, userID,
) )
doc, err := scanDocument(row) doc, err := scanDocument(row)
if err == nil { if err == nil {
@@ -1268,15 +1296,18 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
return doc, nil return doc, nil
} }
// Try shared document // Try shared document (with per-user is_pinned)
sharedRow := s.db.QueryRow( sharedRow := s.db.QueryRow(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, d.is_pinned, d.is_archived, d.created_at, d.updated_at, `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
s.permission, u.display_name s.permission, u.display_name
FROM documents d FROM documents d
JOIN doc_shares s ON d.id = s.doc_id JOIN doc_shares s ON d.id = s.doc_id
JOIN users u ON d.user_id = u.id JOIN users u ON d.user_id = u.id
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.id = ? AND s.user_id = ?`, WHERE d.id = ? AND s.user_id = ?`,
docID, userID, userID, docID, userID,
) )
d := &domain.Document{} d := &domain.Document{}
var blocksStr, tagsStr string var blocksStr, tagsStr string
@@ -1311,10 +1342,31 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
return nil, err return nil, err
} }
if !doc.IsOwner && doc.Permission != "write" { isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange {
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
} }
if isPinned != nil {
doc.IsPinned = *isPinned
if *isPinned {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO())
if doc.IsOwner {
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 1 WHERE id = ?`, docID)
}
} else {
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID)
if doc.IsOwner {
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 0 WHERE id = ?`, docID)
}
}
}
if isOnlyPinChange {
return doc, nil
}
if title != nil { if title != nil {
doc.Title = strings.TrimSpace(*title) doc.Title = strings.TrimSpace(*title)
} }
@@ -1338,22 +1390,15 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
if tags != nil { if tags != nil {
doc.Tags = *tags doc.Tags = *tags
} }
if isPinned != nil {
doc.IsPinned = *isPinned
}
doc.UpdatedAt = domain.NowISO() doc.UpdatedAt = domain.NowISO()
blocksJSON, _ := json.Marshal(doc.Blocks) blocksJSON, _ := json.Marshal(doc.Blocks)
tagsJSON, _ := json.Marshal(doc.Tags) tagsJSON, _ := json.Marshal(doc.Tags)
pinnedInt := 0
if doc.IsPinned {
pinnedInt = 1
}
_, err = s.db.Exec( _, err = s.db.Exec(
`UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, is_pinned = ?, updated_at = ? `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, updated_at = ?
WHERE id = ?`, WHERE id = ?`,
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.UpdatedAt, docID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, doc.UpdatedAt, docID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1957,9 +2002,13 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
} }
dRows, err := s.db.Query( dRows, err := s.db.Query(
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
FROM documents WHERE user_id = ? AND (LOWER(title) LIKE ? OR LOWER(content) LIKE ?) ORDER BY updated_at DESC LIMIT 20`, CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
userID, q, q, d.is_archived, d.created_at, d.updated_at
FROM documents d
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.user_id = ? AND (LOWER(d.title) LIKE ? OR LOWER(d.content) LIKE ?) ORDER BY is_pinned DESC, d.updated_at DESC LIMIT 20`,
userID, userID, q, q,
) )
docs := make([]domain.Document, 0) docs := make([]domain.Document, 0)
if err == nil { if err == nil {
+76
View File
@@ -164,6 +164,82 @@ func TestStoreLifecycle(t *testing.T) {
} }
} }
func TestPerUserDocPinning(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pin_test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
user1, _ := store.CreateUser("user1@example.com", "hash", "User 1", "admin")
user2, _ := store.CreateUser("user2@example.com", "hash", "User 2", "member")
// user1 creates document and pins it
doc, err := store.CreateDocument(user1.ID, "", "Doc 1", "📝", "", []domain.DocumentBlock{
{ID: "b1", Type: "text", Content: "Hello"},
}, []string{"test"}, true)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
if !doc.IsPinned {
t.Fatalf("expected doc to be pinned for user1")
}
// Share with user2 as read-only
_, err = store.CreateDocShare(user1.ID, doc.ID, user2.ID, "read")
if err != nil {
t.Fatalf("CreateDocShare failed: %v", err)
}
// user2 reads document: should NOT be pinned for user2
docForUser2, err := store.GetDocumentByID(user2.ID, doc.ID)
if err != nil {
t.Fatalf("GetDocumentByID user2 failed: %v", err)
}
if docForUser2.IsPinned {
t.Fatalf("expected doc to NOT be pinned for user2 initially")
}
// user2 pins the shared document (even with read-only access)
pinTrue := true
updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, nil, nil, &pinTrue)
if err != nil {
t.Fatalf("user2 pinning read-only doc failed: %v", err)
}
if !updatedDoc2.IsPinned {
t.Fatalf("expected doc to be pinned for user2")
}
// Verify user1's doc is still pinned
docForUser1, err := store.GetDocumentByID(user1.ID, doc.ID)
if err != nil {
t.Fatalf("GetDocumentByID user1 failed: %v", err)
}
if !docForUser1.IsPinned {
t.Fatalf("expected doc to still be pinned for user1")
}
// user1 unpins the doc
pinFalse := false
_, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, nil, nil, &pinFalse)
if err != nil {
t.Fatalf("user1 unpinning doc failed: %v", err)
}
// user1 doc is now unpinned, but user2 doc must STILL be pinned!
docForUser1, _ = store.GetDocumentByID(user1.ID, doc.ID)
if docForUser1.IsPinned {
t.Fatalf("expected doc to be unpinned for user1")
}
docForUser2, _ = store.GetDocumentByID(user2.ID, doc.ID)
if !docForUser2.IsPinned {
t.Fatalf("expected doc to remain pinned for user2")
}
}
func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) { func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "demo_test.db") dbPath := filepath.Join(tmpDir, "demo_test.db")
+3 -3
View File
@@ -117,7 +117,7 @@ func (h *Hub) Unregister(c *Client) {
// Broadcast unlock events for released locks // Broadcast unlock events for released locks
for _, docID := range releasedDocIDs { for _, docID := range releasedDocIDs {
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID}) h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
} }
} }
@@ -233,7 +233,7 @@ func (h *Hub) cleanupLocksLoop() {
h.mu.Unlock() h.mu.Unlock()
for _, docID := range expiredDocIDs { for _, docID := range expiredDocIDs {
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID}) h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
} }
} }
} }
@@ -388,7 +388,7 @@ func (c *Client) readPump() {
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second) c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
case "UNLOCK_DOC": case "UNLOCK_DOC":
if c.hub.ReleaseDocLock(docID, c.userID) { if c.hub.ReleaseDocLock(docID, c.userID) {
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID}) c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
} }
} }
} }