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
+183 -47

No files matched your search

+4 -2
View File
@@ -792,7 +792,9 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", 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")
released := s.wsHub.ReleaseDocLock(docID, userID)
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})
}
+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_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 (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL,
@@ -1153,6 +1167,9 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
if err != nil {
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)
return doc, nil
}
@@ -1180,12 +1197,16 @@ func scanDocument(rows interface {
}
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(
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at
FROM documents WHERE user_id = ? AND is_archived = 0
ORDER BY is_pinned DESC, updated_at DESC`,
userID,
`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
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 {
return nil, err
@@ -1202,16 +1223,19 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
}
_ = 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(
`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
FROM documents d
JOIN doc_shares s ON d.id = s.doc_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
ORDER BY d.updated_at DESC`,
userID,
ORDER BY is_pinned DESC, d.updated_at DESC`,
userID, userID,
)
if err == nil {
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) {
// Try owned document first
// Try owned document first (with per-user is_pinned)
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
FROM documents WHERE id = ? AND user_id = ?`,
docID, userID,
`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
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)
if err == nil {
@@ -1268,15 +1296,18 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
return doc, nil
}
// Try shared document
// Try shared document (with per-user is_pinned)
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
FROM documents d
JOIN doc_shares s ON d.id = s.doc_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 = ?`,
docID, userID,
userID, docID, userID,
)
d := &domain.Document{}
var blocksStr, tagsStr string
@@ -1311,10 +1342,31 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
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)")
}
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 {
doc.Title = strings.TrimSpace(*title)
}
@@ -1338,22 +1390,15 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
if tags != nil {
doc.Tags = *tags
}
if isPinned != nil {
doc.IsPinned = *isPinned
}
doc.UpdatedAt = domain.NowISO()
blocksJSON, _ := json.Marshal(doc.Blocks)
tagsJSON, _ := json.Marshal(doc.Tags)
pinnedInt := 0
if doc.IsPinned {
pinnedInt = 1
}
_, 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 = ?`,
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 {
return nil, err
@@ -1957,9 +2002,13 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
}
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
FROM documents WHERE user_id = ? AND (LOWER(title) LIKE ? OR LOWER(content) LIKE ?) ORDER BY updated_at DESC LIMIT 20`,
userID, q, q,
`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
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)
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) {
tmpDir := t.TempDir()
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
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()
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)
case "UNLOCK_DOC":
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})
}
}
}