feat: implement live-sync for tasks, workspace note sharing, and document locking system
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 53s
This commit is contained in:
1 parent
dccf1a91e3
commit
84e0b6a4bd
10 files changed
+1773
-110
No files matched your search
+243
-6
@@ -199,6 +199,21 @@ func (s *Store) migrate() error {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doc_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL CHECK(permission IN ('read', 'write')),
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE(doc_id, user_id),
|
||||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
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 lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL,
|
||||
@@ -267,6 +282,10 @@ func (s *Store) migrate() error {
|
||||
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
|
||||
|
||||
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS doc_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, user_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, 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);",
|
||||
}
|
||||
for _, q := range columnMigrations {
|
||||
_, _ = s.db.Exec(q)
|
||||
@@ -1099,6 +1118,7 @@ func scanDocument(rows interface {
|
||||
}
|
||||
|
||||
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
||||
// 1. Fetch user's own documents
|
||||
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
|
||||
@@ -1108,25 +1128,119 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
docs := make([]domain.Document, 0)
|
||||
for rows.Next() {
|
||||
d, err := scanDocument(rows)
|
||||
if err == nil {
|
||||
d.Permission = "owner"
|
||||
d.IsOwner = true
|
||||
docs = append(docs, *d)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
// 2. Fetch documents shared with the user
|
||||
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,
|
||||
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
|
||||
WHERE s.user_id = ? AND d.is_archived = 0
|
||||
ORDER BY d.updated_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err == nil {
|
||||
for sharedRows.Next() {
|
||||
d := &domain.Document{}
|
||||
var blocksStr, tagsStr string
|
||||
var isPinned, isArchived int
|
||||
var perm, ownerName string
|
||||
if scanErr := sharedRows.Scan(
|
||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||
&perm, &ownerName,
|
||||
); scanErr == nil {
|
||||
d.IsPinned = isPinned != 0
|
||||
d.IsArchived = isArchived != 0
|
||||
d.Blocks = []domain.DocumentBlock{}
|
||||
d.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Permission = perm
|
||||
d.IsOwner = false
|
||||
d.OwnerName = ownerName
|
||||
docs = append(docs, *d)
|
||||
}
|
||||
}
|
||||
_ = sharedRows.Close()
|
||||
}
|
||||
|
||||
// 3. Attach shares to owned documents
|
||||
for i := range docs {
|
||||
if docs[i].IsOwner {
|
||||
if shares, err := s.GetDocShares(docs[i].ID); err == nil {
|
||||
docs[i].SharedWith = shares
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
||||
// Try owned document first
|
||||
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,
|
||||
)
|
||||
return scanDocument(row)
|
||||
doc, err := scanDocument(row)
|
||||
if err == nil {
|
||||
doc.Permission = "owner"
|
||||
doc.IsOwner = true
|
||||
if shares, err := s.GetDocShares(doc.ID); err == nil {
|
||||
doc.SharedWith = shares
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// Try shared document
|
||||
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,
|
||||
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
|
||||
WHERE d.id = ? AND s.user_id = ?`,
|
||||
docID, userID,
|
||||
)
|
||||
d := &domain.Document{}
|
||||
var blocksStr, tagsStr string
|
||||
var isPinned, isArchived int
|
||||
var perm, ownerName string
|
||||
err = sharedRow.Scan(
|
||||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||||
&perm, &ownerName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.IsPinned = isPinned != 0
|
||||
d.IsArchived = isArchived != 0
|
||||
d.Blocks = []domain.DocumentBlock{}
|
||||
d.Tags = []string{}
|
||||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||||
d.Permission = perm
|
||||
d.IsOwner = false
|
||||
d.OwnerName = ownerName
|
||||
if shares, err := s.GetDocShares(d.ID); err == nil {
|
||||
d.SharedWith = shares
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) {
|
||||
@@ -1135,6 +1249,10 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||
}
|
||||
|
||||
if title != nil {
|
||||
doc.Title = strings.TrimSpace(*title)
|
||||
}
|
||||
@@ -1172,8 +1290,8 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, is_pinned = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.UpdatedAt, docID, userID,
|
||||
WHERE id = ?`,
|
||||
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.UpdatedAt, docID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1183,14 +1301,133 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDocument(userID, docID string) error {
|
||||
doc, _ := s.GetDocumentByID(userID, docID)
|
||||
doc, err := s.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !doc.IsOwner {
|
||||
// Collaborator removing their shared access
|
||||
return s.DeleteDocShare(userID, docID, userID)
|
||||
}
|
||||
if doc != nil {
|
||||
s.removeDocumentFromDisk(doc)
|
||||
}
|
||||
_, err := s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
||||
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
|
||||
FROM doc_shares s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.doc_id = ?
|
||||
ORDER BY s.created_at ASC`,
|
||||
docID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
shares := make([]domain.DocShare, 0)
|
||||
for rows.Next() {
|
||||
var sh domain.DocShare
|
||||
if err := rows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
|
||||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
||||
}
|
||||
|
||||
if permission != "read" && permission != "write" {
|
||||
permission = "read"
|
||||
}
|
||||
|
||||
// Resolve target user by ID or Email
|
||||
var targetUser *domain.User
|
||||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||
if strings.Contains(cleanTarget, "@") {
|
||||
targetUser, err = s.GetUserByEmail(cleanTarget)
|
||||
} else {
|
||||
targetUser, err = s.GetUserByID(cleanTarget)
|
||||
}
|
||||
if err != nil || targetUser == nil {
|
||||
return nil, fmt.Errorf("benutzer nicht gefunden")
|
||||
}
|
||||
|
||||
if targetUser.ID == doc.UserID {
|
||||
return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer")
|
||||
}
|
||||
|
||||
now := domain.NowISO()
|
||||
shareID := domain.NewID("share")
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(doc_id, user_id) DO UPDATE SET permission = excluded.permission`,
|
||||
shareID, docID, targetUser.ID, permission, now, currentUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.DocShare{
|
||||
ID: shareID,
|
||||
DocID: docID,
|
||||
UserID: targetUser.ID,
|
||||
UserEmail: targetUser.Email,
|
||||
DisplayName: targetUser.DisplayName,
|
||||
Permission: permission,
|
||||
CreatedBy: currentUserID,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDocShare(currentUserID, docID, targetUserID string) error {
|
||||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetUserID {
|
||||
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND user_id = ?`, docID, targetUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, email, display_name, role, is_active, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`,
|
||||
currentUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]domain.User, 0)
|
||||
for rows.Next() {
|
||||
var u domain.User
|
||||
var activeInt int
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.CreatedAt); err == nil {
|
||||
u.IsActive = activeInt == 1
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
|
||||
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
|
||||
|
||||
Reference in new issue
Block a user