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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
This commit is contained in:
1 parent
ec79a2493e
commit
af7a9eb200
5 files changed
+183
-47
No files matched your search
+78
-29
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user