package storage import ( "encoding/json" "fmt" "path/filepath" "strings" "github.com/sandrodach/mindsift/internal/domain" ) func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock { if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 { blocks := flatDocumentBlocks(docID, d) if links := s.docTaskLinks(docID); len(links) > 0 { for i := range blocks { blocks[i].TaskID = links[blocks[i].ID] } } return blocks } // Very old notes only stored blocks_json; convert them once blocks := make([]domain.DocumentBlock, 0) if fallbackJSON != "" && fallbackJSON != "[]" { _ = json.Unmarshal([]byte(fallbackJSON), &blocks) if len(blocks) > 0 { s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO()) if d, _, err := loadCollabDoc(s.db, docID); err == nil { return flatDocumentBlocks(docID, d) } } } return blocks } // GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID. func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) { userSet := make(map[string]bool) var ownerID, sharedFolderID string err := s.db.QueryRow(`SELECT user_id, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&ownerID, &sharedFolderID) if err != nil { return nil, err } if ownerID != "" { userSet[ownerID] = true } // Direct user shares uRows, err := s.db.Query(`SELECT user_id FROM doc_shares WHERE doc_id = ?`, docID) if err == nil { for uRows.Next() { var uid string if err := uRows.Scan(&uid); err == nil && uid != "" { userSet[uid] = true } } _ = uRows.Close() } // Team shares tRows, err := s.db.Query(` SELECT tm.user_id FROM doc_team_shares dts JOIN team_members tm ON tm.team_id = dts.team_id WHERE dts.doc_id = ? `, docID) if err == nil { for tRows.Next() { var uid string if err := tRows.Scan(&uid); err == nil && uid != "" { userSet[uid] = true } } _ = tRows.Close() } // Shared folder access if sharedFolderID != "" { sfUsers, err := s.GetAuthorizedSharedFolderUsers(sharedFolderID) if err == nil { for _, uid := range sfUsers { userSet[uid] = true } } } users := make([]string, 0, len(userSet)) for uid := range userSet { users = append(users, uid) } return users, nil } // GetAuthorizedSharedFolderUsers returns all user IDs who have access to a shared folder. func (s *Store) GetAuthorizedSharedFolderUsers(folderID string) ([]string, error) { userSet := make(map[string]bool) var ownerID string if err := s.db.QueryRow(`SELECT owner_id FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&ownerID); err == nil && ownerID != "" { userSet[ownerID] = true } rows, err := s.db.Query(` SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? `, folderID) if err == nil { type shareTarget struct{ tType, tID string } var targets []shareTarget for rows.Next() { var st shareTarget if err := rows.Scan(&st.tType, &st.tID); err == nil { targets = append(targets, st) } } _ = rows.Close() for _, st := range targets { if st.tType == "user" && st.tID != "" { userSet[st.tID] = true } else if st.tType == "team" && st.tID != "" { mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, st.tID) if err == nil { for mRows.Next() { var uid string if err := mRows.Scan(&uid); err == nil && uid != "" { userSet[uid] = true } } _ = mRows.Close() } } } } users := make([]string, 0, len(userSet)) for uid := range userSet { users = append(users, uid) } return users, nil } func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) { if strings.TrimSpace(title) == "" { title = "Unbenannte Notiz" } if icon == "" { icon = "📄" } if blocks == nil { blocks = []domain.DocumentBlock{ {ID: domain.NewID("blk"), Type: "paragraph", Content: ""}, } } tags = stripFolderTags(tags) var plainBuilder strings.Builder for _, b := range blocks { if b.Content != "" { plainBuilder.WriteString(b.Content) plainBuilder.WriteString("\n") } } blocksJSON, _ := json.Marshal(blocks) tagsJSON, _ := json.Marshal(tags) now := domain.NowISO() pinnedInt := 0 if isPinned { pinnedInt = 1 } cleanFolder := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) if cleanFolder == "." { cleanFolder = "" } mounts, _, _ := s.getUserFolderMounts(userID) var sharedFolderID, subFolderPath string if fID, sub, ok := matchMountPath(cleanFolder, mounts); ok { sharedFolderID = fID subFolderPath = sub } doc := &domain.Document{ ID: domain.NewID("doc"), UserID: userID, SpaceID: spaceID, Title: strings.TrimSpace(title), Icon: icon, Content: plainBuilder.String(), Blocks: blocks, Tags: tags, FolderPath: cleanFolder, IsPinned: isPinned, SharedFolderID: sharedFolderID, SubFolderPath: subFolderPath, IsSharedFolder: sharedFolderID != "", Permission: "owner", IsOwner: true, CreatedAt: now, UpdatedAt: now, } _, err := s.db.Exec( `INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at, shared_folder_id, sub_folder_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt, doc.SharedFolderID, doc.SubFolderPath, ) 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.replaceDocBlocksInDB(doc.ID, userID, doc.Blocks, now) _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) return doc, nil } // getUserFolderMounts returns map[folder_id]mount_path and map[folder_id]folder_name for all shared folders accessible to userID func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[string]string, error) { mounts := make(map[string]string) names := make(map[string]string) rows, err := s.db.Query(` SELECT f.id, f.name, COALESCE(m.mount_path, f.name) as mount_path FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND ( (s.target_type = 'user' AND s.target_id = ?) OR (s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) WHERE f.owner_id = ? OR s.id IS NOT NULL GROUP BY f.id `, userID, userID, userID, userID) if err != nil { return mounts, names, err } defer rows.Close() for rows.Next() { var fID, fName, mPath string if err := rows.Scan(&fID, &fName, &mPath); err == nil { names[fID] = fName if mPath == "" { mPath = fName } mounts[fID] = mPath } } return mounts, names, nil } // matchMountPath checks if folderPath falls within any mounted shared folder. // Returns (sharedFolderID, relativeSubPath, isMatch). func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) { clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) if clean == "." { clean = "" } if clean == "" { return "", "", false } var bestFolderID string var bestSubPath string var longestMountLen int = -1 for fID, mPath := range mounts { cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mPath))) if cleanMount == "." { cleanMount = "" } if cleanMount == "" { continue } if clean == cleanMount { if len(cleanMount) > longestMountLen { longestMountLen = len(cleanMount) bestFolderID = fID bestSubPath = "" } } else if strings.HasPrefix(clean, cleanMount+"/") { sub := strings.TrimPrefix(clean, cleanMount+"/") if len(cleanMount) > longestMountLen { longestMountLen = len(cleanMount) bestFolderID = fID bestSubPath = sub } } } if longestMountLen >= 0 { return bestFolderID, bestSubPath, true } return "", "", false } func resolveDocumentPath(userID string, doc *domain.Document, mounts map[string]string, names map[string]string, userFolders map[string]string) { if doc.SharedFolderID != "" { mountPath, ok := mounts[doc.SharedFolderID] if !ok || mountPath == "" { mountPath = names[doc.SharedFolderID] if mountPath == "" { mountPath = "Geteilter Ordner" } } doc.IsSharedFolder = true if doc.SubFolderPath != "" { doc.FolderPath = mountPath + "/" + doc.SubFolderPath } else { doc.FolderPath = mountPath } } else { if uf, ok := userFolders[doc.ID]; ok { doc.FolderPath = uf } else if !doc.IsOwner { doc.FolderPath = "" } } } func scanDocument(rows interface { Scan(dest ...any) error }) (*domain.Document, error) { d := &domain.Document{} var blocksStr, tagsStr string var isPinned, isArchived int err := rows.Scan( &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, &d.SharedFolderID, &d.SubFolderPath, ) 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.Tags = stripFolderTags(d.Tags) return d, nil } func (s *Store) scanDocumentWithBlocks(rows interface { Scan(dest ...any) error }) (*domain.Document, error) { d := &domain.Document{} var blocksStr, tagsStr string var isPinned, isArchived int err := rows.Scan( &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, &d.SharedFolderID, &d.SubFolderPath, ) if err != nil { return nil, err } d.IsPinned = isPinned != 0 d.IsArchived = isArchived != 0 d.Tags = []string{} _ = json.Unmarshal([]byte(tagsStr), &d.Tags) d.Tags = stripFolderTags(d.Tags) d.Blocks = s.loadDocBlocks(d.ID, blocksStr) d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks) d.FolderID = d.FolderPath return d, nil } func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { mounts, names, _ := s.getUserFolderMounts(userID) userFolders := make(map[string]string) ufRows, err := s.db.Query(`SELECT doc_id, folder_path FROM doc_user_folders WHERE user_id = ?`, userID) if err == nil { for ufRows.Next() { var did, fp string if err := ufRows.Scan(&did, &fp); err == nil { userFolders[did] = fp } } _ = ufRows.Close() } // 1. Fetch user's own documents (with per-user is_pinned from doc_pins) rows, 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, 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, d.shared_folder_id, d.sub_folder_path 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 } type rawDocRow struct { doc domain.Document blocksStr string } var ownRaw []rawDocRow for rows.Next() { d := domain.Document{} var blocksStr, tagsStr string var isPinned, isArchived int if err := rows.Scan( &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, &d.SharedFolderID, &d.SubFolderPath, ); err == nil { d.IsPinned = isPinned != 0 d.IsArchived = isArchived != 0 d.Tags = []string{} _ = json.Unmarshal([]byte(tagsStr), &d.Tags) d.Tags = stripFolderTags(d.Tags) d.Permission = "owner" d.IsOwner = true resolveDocumentPath(userID, &d, mounts, names, userFolders) ownRaw = append(ownRaw, rawDocRow{doc: d, blocksStr: blocksStr}) } } _ = rows.Close() docs := make([]domain.Document, 0, len(ownRaw)) for _, r := range ownRaw { r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) docs = append(docs, r.doc) } // 2. Fetch documents shared with the user directly, via teams, or via shared folders 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, 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, d.shared_folder_id, d.sub_folder_path, CASE WHEN wsf.owner_id = ? THEN 'owner' WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write' ELSE 'read' END AS permission, u.display_name FROM documents d JOIN users u ON d.user_id = u.id LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ? LEFT JOIN ( SELECT dts2.doc_id, dts2.permission FROM doc_team_shares dts2 JOIN team_members tm ON tm.team_id = dts2.team_id WHERE tm.user_id = ? ) dts ON d.id = dts.doc_id LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id LEFT JOIN ( SELECT wfs2.folder_id, wfs2.permission FROM workspace_folder_shares wfs2 WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) wfs ON d.shared_folder_id = wfs.folder_id LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? WHERE d.is_archived = 0 AND d.user_id != ? AND ( s.user_id IS NOT NULL OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL ) GROUP BY d.id ORDER BY is_pinned DESC, d.updated_at DESC`, userID, userID, userID, userID, userID, userID, userID, userID, ) if err == nil { var sharedRaw []rawDocRow 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, &d.SharedFolderID, &d.SubFolderPath, &perm, &ownerName, ); scanErr == nil { d.IsPinned = isPinned != 0 d.IsArchived = isArchived != 0 d.Tags = []string{} _ = json.Unmarshal([]byte(tagsStr), &d.Tags) d.Tags = stripFolderTags(d.Tags) d.Permission = perm d.IsOwner = (perm == "owner") d.OwnerName = ownerName resolveDocumentPath(userID, &d, mounts, names, userFolders) sharedRaw = append(sharedRaw, rawDocRow{doc: d, blocksStr: blocksStr}) } } _ = sharedRows.Close() for _, r := range sharedRaw { r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) docs = append(docs, r.doc) } } // 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) { mounts, names, _ := s.getUserFolderMounts(userID) userFolders := make(map[string]string) var personalFp string if err := s.db.QueryRow(`SELECT folder_path FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID).Scan(&personalFp); err == nil { userFolders[docID] = personalFp } // Try owned document first (with per-user is_pinned) row := 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, 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, d.shared_folder_id, d.sub_folder_path 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 = ? OR (d.is_archived = 1 AND d.archived_by = ?))`, userID, docID, userID, userID, ) doc, err := s.scanDocumentWithBlocks(row) if err == nil { doc.Permission = "owner" doc.IsOwner = doc.UserID == userID if doc.IsArchived { doc.Permission = "read" // read-only while in the trash } resolveDocumentPath(userID, doc, mounts, names, userFolders) if shares, err := s.GetDocShares(doc.ID); err == nil { doc.SharedWith = shares } return doc, nil } // Try shared document (direct, via team, or via shared folder) 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, 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, d.shared_folder_id, d.sub_folder_path, CASE WHEN wsf.owner_id = ? THEN 'owner' WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write' ELSE 'read' END AS permission, u.display_name FROM documents d JOIN users u ON d.user_id = u.id LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ? LEFT JOIN ( SELECT dts2.doc_id, dts2.permission FROM doc_team_shares dts2 JOIN team_members tm ON tm.team_id = dts2.team_id WHERE tm.user_id = ? ) dts ON d.id = dts.doc_id LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id LEFT JOIN ( SELECT wfs2.folder_id, wfs2.permission FROM workspace_folder_shares wfs2 WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) wfs ON d.shared_folder_id = wfs.folder_id LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL) LIMIT 1`, userID, userID, userID, userID, userID, userID, docID, userID, 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, &d.SharedFolderID, &d.SubFolderPath, &perm, &ownerName, ) if err != nil { return nil, err } d.IsPinned = isPinned != 0 d.IsArchived = isArchived != 0 d.Tags = []string{} _ = json.Unmarshal([]byte(tagsStr), &d.Tags) d.Tags = stripFolderTags(d.Tags) d.Blocks = s.loadDocBlocks(d.ID, blocksStr) d.Permission = perm d.IsOwner = (perm == "owner") d.OwnerName = ownerName resolveDocumentPath(userID, d, mounts, names, userFolders) 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, isPinned *bool) (*domain.Document, error) { doc, err := s.GetDocumentByID(userID, docID) if err != nil { return nil, err } if doc.IsArchived { return nil, ErrDocInTrash } // Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is // only changed through the operation log (POST /docs/{id}/sync). changesContent := title != nil || icon != nil if changesContent && !CanEditDoc(s.DocRole(userID, docID)) { return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") } if isPinned != nil { doc.IsPinned = *isPinned pin := []sqlStmt{stmt(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID)} if *isPinned { pin = []sqlStmt{stmt(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO())} } if doc.IsOwner { pin = append(pin, stmt(`UPDATE documents SET is_pinned = ? WHERE id = ?`, boolInt(*isPinned), docID)) } if err := s.execInTx(pin...); err != nil { return nil, err } } if folderPath != nil { movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath) if moveErr != nil { return nil, moveErr } doc.FolderPath = movedDoc.FolderPath doc.SharedFolderID = movedDoc.SharedFolderID doc.SubFolderPath = movedDoc.SubFolderPath doc.IsSharedFolder = movedDoc.IsSharedFolder } if !changesContent { return doc, nil } if title != nil { doc.Title = strings.TrimSpace(*title) s.applyLegacyTitle(docID, userID, doc.Title) } if icon != nil { doc.Icon = *icon } doc.UpdatedAt = domain.NowISO() if _, err := s.db.Exec(`UPDATE documents SET icon = ?, updated_at = ? WHERE id = ?`, doc.Icon, doc.UpdatedAt, docID); err != nil { return nil, err } return s.GetDocumentByID(userID, docID) } func (s *Store) DeleteDocument(userID, docID string) error { doc, err := s.GetDocumentByID(userID, docID) if err != nil { return err } if !doc.IsOwner { // Collaborator removing their shared access if _, err := s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID); err != nil { return err } return s.DeleteDocShare(userID, docID, userID) } if doc.IsArchived { return s.hardDeleteDocument(docID) } return s.trashDocument(userID, docID) } func (s *Store) reindexAllDocBlockTags() error { rows, err := s.db.Query(`SELECT id, user_id, blocks_json FROM documents WHERE is_archived = 0`) if err != nil { return err } defer rows.Close() type docInfo struct { id, userID, blocksJSON string } var docs []docInfo for rows.Next() { var d docInfo if err := rows.Scan(&d.id, &d.userID, &d.blocksJSON); err == nil { docs = append(docs, d) } } _ = rows.Close() for _, d := range docs { blocks := s.loadDocBlocks(d.id, d.blocksJSON) if len(blocks) > 0 { _ = s.indexDocBlockTags(d.id, d.userID, blocks) } } return nil } func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error { _, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID) if len(blocks) == 0 { return nil } stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`) if err != nil { return err } defer stmt.Close() for _, b := range blocks { if b.Content == "" { continue } tags := domain.ExtractTags(b.Content) for _, tag := range tags { _, _ = stmt.Exec(tag, docID, b.ID) } } return nil } func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) { shares := make([]domain.DocShare, 0) // 1. Direct user shares uRows, 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 { for uRows.Next() { var sh domain.DocShare if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { sh.TargetType = "user" sh.TargetID = sh.UserID sh.TargetName = sh.DisplayName shares = append(shares, sh) } } _ = uRows.Close() } // 2. Team shares tRows, err := s.db.Query( `SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at FROM doc_team_shares s JOIN teams t ON s.team_id = t.id WHERE s.doc_id = ? ORDER BY s.created_at ASC`, docID, ) if err == nil { for tRows.Next() { var sh domain.DocShare var teamID, teamName string if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { sh.TargetType = "team" sh.TargetID = teamID sh.TargetName = teamName sh.DisplayName = teamName shares = append(shares, sh) } } _ = tRows.Close() } return shares, nil } func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) { doc, err := s.GetDocumentByID(currentUserID, docID) if err != nil { return nil, err } if !doc.IsOwner { return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") } cleanTeamID := strings.TrimSpace(teamID) var teamName string err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName) if err != nil || !s.CanShareWith(currentUserID, "team", cleanTeamID) { return nil, ErrTargetNotFound } permission = normalizeSharePermission(permission) now := domain.NowISO() shareID := domain.NewID("share") _, err = s.db.Exec( `INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`, shareID, docID, cleanTeamID, permission, now, currentUserID, ) if err != nil { return nil, err } return &domain.DocShare{ ID: shareID, DocID: docID, TargetType: "team", TargetID: cleanTeamID, TargetName: teamName, DisplayName: teamName, Permission: permission, CreatedBy: currentUserID, CreatedAt: now, }, nil } // normalizeSharePermission maps share roles from the API to the stored values: "write" (editor) or "read" (viewer). func normalizeSharePermission(p string) string { switch strings.ToLower(strings.TrimSpace(p)) { case "write", "editor": return "write" } return "read" } 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.IsArchived { return nil, ErrDocInTrash } if !doc.IsOwner { return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") } cleanTarget := strings.TrimSpace(targetUserIdentifier) // If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare if strings.HasPrefix(cleanTarget, "team_") { return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) } permission = normalizeSharePermission(permission) // Resolve target user by ID, username, or Email var targetUser *domain.User if strings.HasPrefix(cleanTarget, "usr_") { targetUser, _ = s.GetUserByID(cleanTarget) } if targetUser == nil { targetUser, _ = s.GetUserByLogin(cleanTarget) } if targetUser == nil { targetUser, err = s.GetUserByID(cleanTarget) } if err != nil || targetUser == nil { // Check if it's a team ID that doesn't start with team_ var tName string if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil { return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) } return nil, ErrTargetNotFound } if targetUser.ID == doc.UserID { return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer") } if !s.CanShareWith(currentUserID, "user", targetUser.ID) { return nil, ErrTargetNotFound } 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, TargetType: "user", TargetID: targetUser.ID, TargetName: targetUser.DisplayName, UserID: targetUser.ID, UserEmail: targetUser.Email, DisplayName: targetUser.DisplayName, Permission: permission, CreatedBy: currentUserID, CreatedAt: now, }, nil } func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error { doc, err := s.GetDocumentByID(currentUserID, docID) if err != nil { return err } // Only the owner manages shares; everyone else can only remove their own if !doc.IsOwner && currentUserID != targetID { return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe") } cleanTarget := strings.TrimSpace(targetID) // Try deleting team share first res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget) if err == nil { if rows, _ := res.RowsAffected(); rows > 0 { return nil } } // Fallback to user share _, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget) return err } // GetCollaborators returns the active people the user can share with (everyone visible except the user). func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { visible, err := s.GetDiscoverableUsers(currentUserID) if err != nil { return nil, err } users := make([]domain.User, 0, len(visible)) for _, u := range visible { if u.ID != currentUserID && u.IsActive { users = append(users, u) } } return users, nil }