feat(workspace): überarbeite geteilte Notizen und ganze Ordner teilen
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s

This commit is contained in:
sandrodach committed 2026-10-26 08:26:24 +01:00
1 parent 3fcd164fd8
commit 35fd682fcd
6 files changed
+1213 -93

No files matched your search

+163
View File
@@ -106,6 +106,15 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/folders", s.handleCreateFolder)
r.Delete("/api/v1/folders", s.handleDeleteFolder)
r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders)
r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder)
r.Get("/api/v1/workspace/folders/shared/{id}", s.handleGetSharedFolderByID)
r.Delete("/api/v1/workspace/folders/shared/{id}", s.handleDeleteSharedFolder)
r.Put("/api/v1/workspace/folders/shared/{id}/mount", s.handleUpdateFolderMount)
r.Get("/api/v1/workspace/folders/shared/{id}/shares", s.handleGetFolderShares)
r.Post("/api/v1/workspace/folders/shared/{id}/shares", s.handleCreateFolderShare)
r.Delete("/api/v1/workspace/folders/shared/{id}/shares/{shareId}", s.handleDeleteFolderShare)
// Workspace Tags & Pages
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
@@ -115,6 +124,7 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/documents", s.handleCreateDocument)
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
// Document Sharing & Collaboration
@@ -913,6 +923,159 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
})
}
// --- WORKSPACE SHARED FOLDERS ---
func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetSharedFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
folder, err := s.store.CreateSharedFolder(userID, req.Name, req.MountPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", folder)
writeJSON(w, http.StatusCreated, folder)
}
func (s *Server) handleGetSharedFolderByID(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
folder, err := s.store.GetSharedFolderByID(userID, folderID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, folder)
}
func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
var req struct {
MountPath string `json:"mountPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.UpdateFolderMount(userID, folderID, req.MountPath); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath})
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (s *Server) handleGetFolderShares(w http.ResponseWriter, r *http.Request) {
folderID := chi.URLParam(r, "id")
shares, err := s.store.GetFolderShares(folderID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
var req struct {
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
UserId string `json:"userId"`
Email string `json:"email"`
TeamId string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetType := req.TargetType
targetIdentifier := req.TargetID
if targetIdentifier == "" {
if req.TeamId != "" {
targetType = "team"
targetIdentifier = req.TeamId
} else if req.UserId != "" {
targetType = "user"
targetIdentifier = req.UserId
} else if req.Email != "" {
targetType = "user"
targetIdentifier = req.Email
}
}
share, err := s.store.CreateFolderShare(userID, folderID, targetType, targetIdentifier, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share)
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
FolderPath string `json:"folderPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, err := s.store.MoveDocument(userID, docID, req.FolderPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil)
writeJSON(w, http.StatusOK, doc)
}
// --- WORKSPACE TAGS & PAGES ---
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
+34 -4
View File
@@ -306,10 +306,14 @@ type Document struct {
UpdatedAt string `json:"updatedAt"`
// Collaboration & Permissions
Permission string `json:"permission"` // owner | write | read
IsOwner bool `json:"isOwner"`
OwnerName string `json:"ownerName,omitempty"`
SharedWith []DocShare `json:"sharedWith,omitempty"`
Permission string `json:"permission"` // owner | write | read
IsOwner bool `json:"isOwner"`
OwnerName string `json:"ownerName,omitempty"`
SharedWith []DocShare `json:"sharedWith,omitempty"`
SharedFolderID string `json:"sharedFolderId,omitempty"`
SubFolderPath string `json:"subFolderPath,omitempty"`
IsSharedFolder bool `json:"isSharedFolder,omitempty"`
FolderPermission string `json:"folderPermission,omitempty"`
}
type TagSummary struct {
@@ -421,3 +425,29 @@ type FullExport struct {
Lists []SharedList `json:"lists"`
TaskLists []TaskList `json:"taskLists,omitempty"`
}
type SharedFolder struct {
ID string `json:"id"`
OwnerID string `json:"ownerId"`
OwnerName string `json:"ownerName,omitempty"`
Name string `json:"name"`
MountPath string `json:"mountPath"`
IsOwner bool `json:"isOwner"`
Permission string `json:"permission"` // owner | write | read
Shares []FolderShare `json:"shares,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type FolderShare struct {
ID string `json:"id"`
FolderID string `json:"folderId"`
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
Permission string `json:"permission"` // read | write
CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"`
}
+704 -63
View File
@@ -344,6 +344,24 @@ func (s *Store) migrate() error {
"CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));",
"CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);",
"CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);",
// Workspace Shared Folders & User Folder Placements
"ALTER TABLE documents ADD COLUMN shared_folder_id TEXT NOT NULL DEFAULT '';",
"ALTER TABLE documents ADD COLUMN sub_folder_path TEXT NOT NULL DEFAULT '';",
"CREATE INDEX IF NOT EXISTS idx_documents_shared_folder ON documents(shared_folder_id);",
"CREATE TABLE IF NOT EXISTS workspace_shared_folders (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);",
"CREATE INDEX IF NOT EXISTS idx_workspace_shared_folders_owner ON workspace_shared_folders(owner_id);",
"CREATE TABLE IF NOT EXISTS workspace_folder_shares (id TEXT PRIMARY KEY, folder_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(folder_id, target_type, target_id));",
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_shares_folder ON workspace_folder_shares(folder_id);",
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_shares_target ON workspace_folder_shares(target_type, target_id);",
"CREATE TABLE IF NOT EXISTS workspace_folder_mounts (user_id TEXT NOT NULL, folder_id TEXT NOT NULL, mount_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, folder_id));",
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_mounts_user ON workspace_folder_mounts(user_id);",
"CREATE TABLE IF NOT EXISTS doc_user_folders (user_id TEXT NOT NULL, doc_id TEXT NOT NULL, folder_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, doc_id));",
"CREATE INDEX IF NOT EXISTS idx_doc_user_folders_user ON doc_user_folders(user_id);",
}
for _, q := range columnMigrations {
_, _ = s.db.Exec(q)
@@ -1290,6 +1308,13 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
pinnedInt = 1
}
mounts, _, _ := s.getUserFolderMounts(userID)
var sharedFolderID, subFolderPath string
if fID, sub, ok := matchMountPath(folderPath, mounts); ok {
sharedFolderID = fID
subFolderPath = sub
}
doc := &domain.Document{
ID: domain.NewID("doc"),
UserID: userID,
@@ -1299,16 +1324,18 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
Content: plainBuilder.String(),
Blocks: blocks,
Tags: tags,
FolderPath: folderPath,
IsPinned: isPinned,
CreatedAt: now,
UpdatedAt: now,
FolderPath: folderPath,
IsPinned: isPinned,
SharedFolderID: sharedFolderID,
SubFolderPath: subFolderPath,
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)
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,
`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
@@ -1321,6 +1348,110 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
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) {
@@ -1330,6 +1461,7 @@ func scanDocument(rows interface {
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
@@ -1344,11 +1476,26 @@ func scanDocument(rows interface {
}
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.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
@@ -1365,17 +1512,24 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
if err == nil {
d.Permission = "owner"
d.IsOwner = true
resolveDocumentPath(userID, d, mounts, names, userFolders)
docs = append(docs, *d)
}
}
_ = rows.Close()
// 2. Fetch documents shared with the user directly or via teams (with per-user is_pinned from doc_pins)
// 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,
COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name
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 = ?
@@ -1385,11 +1539,20 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
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 (s.user_id = ? OR dts.doc_id IS NOT NULL) AND d.is_archived = 0 AND d.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, userID, userID, userID, userID, userID,
)
if err == nil {
for sharedRows.Next() {
@@ -1400,6 +1563,7 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
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
@@ -1409,8 +1573,9 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Permission = perm
d.IsOwner = false
d.IsOwner = (perm == "owner")
d.OwnerName = ownerName
resolveDocumentPath(userID, d, mounts, names, userFolders)
docs = append(docs, *d)
}
}
@@ -1430,11 +1595,19 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
}
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.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 = ?`,
@@ -1444,18 +1617,25 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
if err == nil {
doc.Permission = "owner"
doc.IsOwner = true
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 or team share, with per-user is_pinned)
// 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,
COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name
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 = ?
@@ -1465,10 +1645,17 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
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 (s.user_id = ? OR dts.doc_id IS NOT NULL)
WHERE d.id = ? 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, docID, userID,
userID, userID, userID, userID, userID, userID, docID, userID, userID,
)
d := &domain.Document{}
var blocksStr, tagsStr string
@@ -1477,6 +1664,7 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
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 {
@@ -1489,8 +1677,9 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
d.Permission = perm
d.IsOwner = false
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
}
@@ -1504,8 +1693,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
}
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange {
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange && !isOnlyFolderMove {
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
}
@@ -1528,6 +1718,21 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
return doc, nil
}
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 isOnlyFolderMove {
return doc, nil
}
if title != nil {
doc.Title = strings.TrimSpace(*title)
}
@@ -1883,61 +2088,37 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
func (s *Store) GetFolders(userID string) ([]string, error) {
folderSet := make(map[string]bool)
// 1. Collect from SQLite documents (owned by user or shared with user)
rows, err := s.db.Query(
`SELECT DISTINCT folder_path FROM documents
WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?))
AND is_archived = 0 AND folder_path != ''`,
userID, userID,
)
// 1. Collect from resolved documents for this user
docs, err := s.GetDocuments(userID)
if err == nil {
for rows.Next() {
var fp string
if err := rows.Scan(&fp); err == nil && fp != "" {
clean := filepath.ToSlash(filepath.Clean(fp))
parts := strings.Split(clean, "/")
var cur string
for _, p := range parts {
if p != "" && p != "." {
if cur == "" {
cur = p
} else {
cur = cur + "/" + p
}
folderSet[cur] = true
}
}
for _, doc := range docs {
if doc.FolderPath != "" {
addFolderSegments(doc.FolderPath, folderSet)
}
}
_ = rows.Close()
}
// 2. Scan physical directories on disk in docDir/userID
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
mounts, _, err := s.getUserFolderMounts(userID)
if err == nil {
for _, mPath := range mounts {
if mPath != "" {
addFolderSegments(mPath, folderSet)
}
}
}
// 3. Scan physical directories on disk in docDir/userID
if s.docDir != "" {
userDir := filepath.Join(s.docDir, userID)
if fi, err := os.Stat(userDir); err == nil && fi.IsDir() {
_ = filepath.Walk(userDir, func(path string, info os.FileInfo, err error) error {
if err != nil || !info.IsDir() {
return nil
}
if path == userDir {
if err != nil || !info.IsDir() || path == userDir {
return nil
}
rel, err := filepath.Rel(userDir, path)
if err == nil && rel != "" && rel != "." {
clean := filepath.ToSlash(filepath.Clean(rel))
parts := strings.Split(clean, "/")
var cur string
for _, p := range parts {
if p != "" && p != "." {
if cur == "" {
cur = p
} else {
cur = cur + "/" + p
}
folderSet[cur] = true
}
}
addFolderSegments(rel, folderSet)
}
return nil
})
@@ -1952,6 +2133,25 @@ func (s *Store) GetFolders(userID string) ([]string, error) {
return result, nil
}
func addFolderSegments(fp string, set map[string]bool) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(fp)))
if clean == "." || clean == "" {
return
}
parts := strings.Split(clean, "/")
var cur string
for _, p := range parts {
if p != "" && p != "." {
if cur == "" {
cur = p
} else {
cur = cur + "/" + p
}
set[cur] = true
}
}
}
func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
@@ -2010,6 +2210,446 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
return deletedDocIDs, nil
}
func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Document, error) {
doc, err := s.GetDocumentByID(userID, docID)
if err != nil {
return nil, err
}
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath)))
if cleanTarget == "." {
cleanTarget = ""
}
mounts, _, err := s.getUserFolderMounts(userID)
if err != nil {
return nil, err
}
now := domain.NowISO()
// Check if target folder falls within any mounted shared folder
targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts)
if isTargetShared {
// Moving into / within a shared folder
shFolder, err := s.GetSharedFolderByID(userID, targetFolderID)
if err != nil {
return nil, err
}
if !shFolder.IsOwner && shFolder.Permission != "write" {
return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner")
}
// Update document to belong to targetSharedFolderID
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`,
targetFolderID, subPath, now, docID)
if err != nil {
return nil, err
}
// Remove personal override if any
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
doc.SharedFolderID = targetFolderID
doc.SubFolderPath = subPath
doc.IsSharedFolder = true
doc.FolderPath = cleanTarget
return doc, nil
}
// Target is NOT inside a shared folder (personal / standalone)
if doc.SharedFolderID != "" {
// Moving OUT of a shared folder into a personal folder
if !doc.IsOwner && doc.Permission != "write" {
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
}
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
cleanTarget, now, docID)
if err != nil {
return nil, err
}
doc.SharedFolderID = ""
doc.SubFolderPath = ""
doc.IsSharedFolder = false
doc.FolderPath = cleanTarget
return doc, nil
}
// Standalone note
if doc.IsOwner {
// Owner moves their own document -> updates documents.folder_path
_, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID)
if err != nil {
return nil, err
}
doc.FolderPath = cleanTarget
return doc, nil
}
// Recipient moves an individually shared note -> updates doc_user_folders ONLY!
_, err = s.db.Exec(`
INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at
`, userID, docID, cleanTarget, now, now)
if err != nil {
return nil, err
}
doc.FolderPath = cleanTarget
return doc, nil
}
func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) {
cleanName := strings.TrimSpace(folderName)
if cleanName == "" {
return nil, fmt.Errorf("ordnername darf nicht leer sein")
}
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath)))
if cleanMount == "." || cleanMount == "" {
cleanMount = cleanName
}
// Check if already shared by this user at this mount path
var existingID string
err := s.db.QueryRow(`
SELECT f.id FROM workspace_shared_folders f
JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?)
`, userID, userID, cleanMount, cleanName).Scan(&existingID)
if err == nil && existingID != "" {
return s.GetSharedFolderByID(userID, existingID)
}
now := domain.NowISO()
folderID := domain.NewID("fld")
_, err = s.db.Exec(`
INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, folderID, userID, cleanName, now, now)
if err != nil {
return nil, err
}
_, err = s.db.Exec(`
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, userID, folderID, cleanMount, now, now)
if err != nil {
return nil, err
}
// Link all existing documents in cleanMount to this shared folder
rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`,
userID, cleanMount, cleanMount+"/%")
if err == nil {
type docEntry struct{ id, fp string }
var entries []docEntry
for rows.Next() {
var de docEntry
if err := rows.Scan(&de.id, &de.fp); err == nil {
entries = append(entries, de)
}
}
_ = rows.Close()
for _, de := range entries {
var subPath string
if de.fp == cleanMount {
subPath = ""
} else if strings.HasPrefix(de.fp, cleanMount+"/") {
subPath = strings.TrimPrefix(de.fp, cleanMount+"/")
}
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`,
folderID, subPath, de.id)
}
}
var ownerName string
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName)
return &domain.SharedFolder{
ID: folderID,
OwnerID: userID,
OwnerName: ownerName,
Name: cleanName,
MountPath: cleanMount,
IsOwner: true,
Permission: "owner",
Shares: []domain.FolderShare{},
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (s *Store) GetSharedFolders(userID string) ([]domain.SharedFolder, error) {
rows, err := s.db.Query(`
SELECT f.id, f.owner_id, u.display_name, f.name,
COALESCE(m.mount_path, f.name) as mount_path,
CASE
WHEN f.owner_id = ? THEN 1
ELSE 0
END as is_owner,
CASE
WHEN f.owner_id = ? THEN 'owner'
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
ELSE 'read'
END as permission,
f.created_at, f.updated_at
FROM workspace_shared_folders f
JOIN users u ON f.owner_id = u.id
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
ORDER BY f.created_at ASC
`, userID, userID, userID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
folders := make([]domain.SharedFolder, 0)
for rows.Next() {
var f domain.SharedFolder
var isOwner int
if err := rows.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err == nil {
f.IsOwner = isOwner != 0
if f.IsOwner {
if shares, err := s.GetFolderShares(f.ID); err == nil {
f.Shares = shares
}
}
folders = append(folders, f)
}
}
return folders, nil
}
func (s *Store) GetSharedFolderByID(userID, folderID string) (*domain.SharedFolder, error) {
row := s.db.QueryRow(`
SELECT f.id, f.owner_id, u.display_name, f.name,
COALESCE(m.mount_path, f.name) as mount_path,
CASE
WHEN f.owner_id = ? THEN 1
ELSE 0
END as is_owner,
CASE
WHEN f.owner_id = ? THEN 'owner'
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
ELSE 'read'
END as permission,
f.created_at, f.updated_at
FROM workspace_shared_folders f
JOIN users u ON f.owner_id = u.id
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.id = ? AND (f.owner_id = ? OR s.id IS NOT NULL)
GROUP BY f.id
`, userID, userID, userID, userID, userID, folderID, userID)
var f domain.SharedFolder
var isOwner int
if err := row.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, fmt.Errorf("geteilter Ordner nicht gefunden")
}
f.IsOwner = isOwner != 0
if f.IsOwner {
if shares, err := s.GetFolderShares(f.ID); err == nil {
f.Shares = shares
}
}
return &f, nil
}
func (s *Store) UpdateFolderMount(userID, folderID, mountPath string) error {
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mountPath)))
if cleanMount == "." || cleanMount == "" {
return fmt.Errorf("ungültiger Mount-Pfad")
}
now := domain.NowISO()
_, err := s.db.Exec(`
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, folder_id) DO UPDATE SET mount_path = excluded.mount_path, updated_at = excluded.updated_at
`, userID, folderID, cleanMount, now, now)
return err
}
func (s *Store) GetFolderShares(folderID string) ([]domain.FolderShare, error) {
shares := make([]domain.FolderShare, 0)
rows, err := s.db.Query(`
SELECT s.id, s.folder_id, s.target_type, s.target_id,
CASE
WHEN s.target_type = 'user' THEN u.display_name
WHEN s.target_type = 'team' THEN t.name
ELSE ''
END as target_name,
COALESCE(u.email, '') as email,
COALESCE(u.avatar_url, '') as avatar_url,
s.permission, s.created_by, s.created_at
FROM workspace_folder_shares s
LEFT JOIN users u ON s.target_type = 'user' AND s.target_id = u.id
LEFT JOIN teams t ON s.target_type = 'team' AND s.target_id = t.id
WHERE s.folder_id = ?
ORDER BY s.created_at ASC
`, folderID)
if err != nil {
return shares, err
}
defer rows.Close()
for rows.Next() {
var sh domain.FolderShare
if err := rows.Scan(&sh.ID, &sh.FolderID, &sh.TargetType, &sh.TargetID, &sh.TargetName, &sh.Email, &sh.AvatarURL, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
shares = append(shares, sh)
}
}
return shares, nil
}
func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIdentifier, permission string) (*domain.FolderShare, error) {
f, err := s.GetSharedFolderByID(currentUserID, folderID)
if err != nil {
return nil, err
}
if !f.IsOwner && f.Permission != "write" {
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten")
}
cleanType := strings.TrimSpace(targetType)
cleanTarget := strings.TrimSpace(targetIdentifier)
if cleanType == "" {
if strings.HasPrefix(cleanTarget, "team_") {
cleanType = "team"
} else {
cleanType = "user"
}
}
if permission != "read" && permission != "write" {
permission = "read"
}
var targetID, targetName, email, avatarURL string
if cleanType == "team" {
err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName)
if err != nil {
return nil, fmt.Errorf("gruppe nicht gefunden")
}
} else {
var u *domain.User
if strings.HasPrefix(cleanTarget, "usr_") {
u, _ = s.GetUserByID(cleanTarget)
}
if u == nil {
u, _ = s.GetUserByLogin(cleanTarget)
}
if u == nil {
u, err = s.GetUserByID(cleanTarget)
}
if err != nil || u == nil {
if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil {
cleanType = "team"
} else {
return nil, fmt.Errorf("benutzer nicht gefunden")
}
} else {
if u.ID == f.OwnerID {
return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer")
}
targetID = u.ID
targetName = u.DisplayName
email = u.Email
avatarURL = u.AvatarURL
}
}
now := domain.NowISO()
shareID := domain.NewID("fsh")
_, err = s.db.Exec(`
INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission
`, shareID, folderID, cleanType, targetID, permission, currentUserID, now)
if err != nil {
return nil, err
}
if cleanType == "user" {
_, _ = s.db.Exec(`
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, targetID, folderID, f.Name, now, now)
}
return &domain.FolderShare{
ID: shareID,
FolderID: folderID,
TargetType: cleanType,
TargetID: targetID,
TargetName: targetName,
Email: email,
AvatarURL: avatarURL,
Permission: permission,
CreatedBy: currentUserID,
CreatedAt: now,
}, nil
}
func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error {
f, err := s.GetSharedFolderByID(currentUserID, folderID)
if err != nil {
return err
}
cleanID := strings.TrimSpace(shareID)
if !f.IsOwner && f.Permission != "write" {
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`,
folderID, currentUserID, cleanID, cleanID)
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID)
return err
}
var targetType, targetID string
_ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`,
folderID, cleanID, cleanID).Scan(&targetType, &targetID)
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID)
if err != nil {
return err
}
if targetType == "user" && targetID != "" {
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID)
}
return nil
}
func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
f, err := s.GetSharedFolderByID(currentUserID, folderID)
if err != nil {
return err
}
if !f.IsOwner {
return s.DeleteFolderShare(currentUserID, folderID, currentUserID)
}
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID)
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID)
_, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID)
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
return err
}
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
shares := make([]domain.DocShare, 0)
@@ -2781,7 +3421,8 @@ func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Docu
dRows, 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.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 (LOWER(d.title) LIKE ? OR LOWER(d.content) LIKE ?) ORDER BY is_pinned DESC, d.updated_at DESC LIMIT 20`,