Files
MindSift/internal/storage/folders.go
T
sandrodachandClaude Opus 5.5 74c394efaa
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
refactor: split sqlite.go and server.go by domain (FLAW-20)
Pure moves, no behaviour change: the storage layer now has users,
apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and
system files next to sqlite.go (store, schema, migrations); the API has
handlers_*.go per area plus realtime.go and alarms.go next to server.go
(server, router, middleware). Declarations were moved verbatim with an
AST-based tool; imports pruned with goimports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:14:33 +01:00

863 lines
27 KiB
Go

package storage
import (
"database/sql"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
func (s *Store) GetFolders(userID string) ([]string, error) {
folderSet := make(map[string]bool)
// 1. Collect from resolved documents for this user
docs, err := s.GetDocuments(userID)
if err == nil {
for _, doc := range docs {
if doc.FolderPath != "" {
addFolderSegments(doc.FolderPath, folderSet)
}
}
}
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
mounts, _, err := s.getUserFolderMounts(userID)
if err == nil {
for sfID, mPath := range mounts {
if mPath != "" {
addFolderSegments(mPath, folderSet)
// 2b. Add all subfolders created in this shared folder
sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
if err == nil {
for sRows.Next() {
var sp string
if err := sRows.Scan(&sp); err == nil && sp != "" {
fullP := mPath + "/" + sp
addFolderSegments(fullP, folderSet)
}
}
_ = sRows.Close()
}
}
}
}
// 3. Add personal empty folders created by this user
pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID)
if err == nil {
for pRows.Next() {
var p string
if err := pRows.Scan(&p); err == nil && p != "" {
addFolderSegments(p, folderSet)
}
}
_ = pRows.Close()
}
result := make([]string, 0, len(folderSet))
for f := range folderSet {
result = append(result, f)
}
sort.Strings(result)
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, "..") {
return "", fmt.Errorf("ungültiger Ordnerpfad")
}
mounts, _, err := s.getUserFolderMounts(userID)
var sfID, subPath string
if err == nil {
sfID, subPath, _ = matchMountPath(clean, mounts)
}
now := time.Now().UTC().Format(time.RFC3339)
id := domain.NewID("wfld")
if sfID != "" {
var existsID string
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID)
if err == sql.ErrNoRows {
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
id, userID, sfID, clean, subPath, now)
}
} else {
var existsID string
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID)
if err == sql.ErrNoRows {
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`,
id, userID, clean, now)
}
}
return clean, nil
}
func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
return nil, fmt.Errorf("ungültiger Ordnerpfad")
}
var deletedDocIDs []string
seenDocID := make(map[string]bool)
mounts, _, err := s.getUserFolderMounts(userID)
if err == nil {
sfID, subPath, isMatch := matchMountPath(clean, mounts)
if isMatch && sfID != "" {
if subPath == "" {
// Root of a shared folder
shFolder, sfErr := s.GetSharedFolderByID(userID, sfID)
if sfErr == nil && !shFolder.IsOwner {
// Collaborator removing mount/share
_ = s.DeleteSharedFolder(userID, sfID)
return []string{}, nil
}
rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID)
if qErr == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
seenDocID[id] = true
deletedDocIDs = append(deletedDocIDs, id)
}
}
_ = rows.Close()
}
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
} else {
// Subfolder inside a shared folder
rows, qErr := s.db.Query(
`SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`,
sfID, subPath, subPath+"/%",
)
if qErr == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
seenDocID[id] = true
deletedDocIDs = append(deletedDocIDs, id)
}
}
_ = rows.Close()
}
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`,
sfID, subPath, subPath+"/%")
}
}
}
// Find all personal documents owned by this user in this folder and subfolders
rows, err := s.db.Query(
`SELECT id FROM documents
WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`,
userID, clean, clean+"/%",
)
if err != nil {
return nil, err
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
seenDocID[id] = true
deletedDocIDs = append(deletedDocIDs, id)
}
}
_ = rows.Close()
// The notes of the folder go to the trash; restoring one creates its folder again
for _, id := range deletedDocIDs {
_ = s.trashDocument(userID, id)
}
// Also clear any personal folder placements for shared notes in this folder
_, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%")
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%")
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
}
if doc.IsArchived {
return nil, ErrDocInTrash
}
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 {
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
}
// 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 {
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
}
// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path.
func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) {
cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath)))
if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") {
return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad")
}
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath)))
if cleanTarget == "." {
cleanTarget = ""
}
if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) {
return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden")
}
folderName := filepath.Base(cleanSource)
var newFolderPath string
if cleanTarget == "" {
newFolderPath = folderName
} else {
newFolderPath = cleanTarget + "/" + folderName
}
if newFolderPath == cleanSource {
return cleanSource, []string{}, nil
}
// 1. Check if sourcePath is the root mount of a shared folder
mounts, _, err := s.getUserFolderMounts(userID)
if err == nil {
for sfID, mPath := range mounts {
if mPath == cleanSource {
err := s.UpdateFolderMount(userID, sfID, newFolderPath)
if err != nil {
return "", nil, err
}
return newFolderPath, []string{}, nil
}
}
}
// 2. Move all affected documents
docs, err := s.GetDocuments(userID)
if err != nil {
return "", nil, err
}
var movedDocIDs []string
for _, doc := range docs {
if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") {
var nextDocFolder string
if doc.FolderPath == cleanSource {
nextDocFolder = newFolderPath
} else {
suffix := strings.TrimPrefix(doc.FolderPath, cleanSource)
nextDocFolder = newFolderPath + suffix
}
_, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder)
if moveErr == nil {
movedDocIDs = append(movedDocIDs, doc.ID)
}
}
}
// 3. Re-map workspace_folders entries
targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts)
sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts)
wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`)
if qErr == nil {
type wfRecord struct {
id, uid, sfID, path, subPath string
}
var records []wfRecord
for wfRows.Next() {
var r wfRecord
if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil {
records = append(records, r)
}
}
_ = wfRows.Close()
for _, r := range records {
if isSourceSF && r.sfID == sourceSFID {
matches := false
var suffix string
if sourceSub == "" {
matches = true
suffix = "/" + r.subPath
} else if r.subPath == sourceSub {
matches = true
suffix = ""
} else if strings.HasPrefix(r.subPath, sourceSub+"/") {
matches = true
suffix = strings.TrimPrefix(r.subPath, sourceSub)
}
if matches {
var newSub string
if targetSub == "" {
newSub = strings.TrimPrefix(folderName+suffix, "/")
} else {
newSub = targetSub + "/" + folderName + suffix
}
if isTargetSF {
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id)
} else {
newP := newFolderPath + suffix
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id)
}
}
} else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") {
suffix := strings.TrimPrefix(r.path, cleanSource)
newP := newFolderPath + suffix
if isTargetSF {
var newSub string
if targetSub == "" {
newSub = strings.TrimPrefix(folderName+suffix, "/")
} else {
newSub = targetSub + "/" + folderName + suffix
}
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id)
} else {
_, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id)
}
}
}
}
_, _ = s.CreateFolder(userID, newFolderPath)
return newFolderPath, movedDocIDs, nil
}
// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID.
func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) {
if ownerID == "" || folderID == "" || cleanMount == "" {
return
}
// 1. Link all documents owned by ownerID 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 ?)`,
ownerID, 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)
}
}
// 2. Link any workspace_folders created by ownerID in cleanMount
fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`,
ownerID, cleanMount, cleanMount+"/%")
if err == nil {
type wfEntry struct{ id, path string }
var wfEntries []wfEntry
for fRows.Next() {
var wfe wfEntry
if err := fRows.Scan(&wfe.id, &wfe.path); err == nil {
wfEntries = append(wfEntries, wfe)
}
}
_ = fRows.Close()
for _, wfe := range wfEntries {
subP := ""
if strings.HasPrefix(wfe.path, cleanMount+"/") {
subP = strings.TrimPrefix(wfe.path, cleanMount+"/")
}
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`,
folderID, subP, wfe.id)
}
}
}
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 != "" {
s.syncSharedFolderContents(userID, existingID, cleanMount)
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
}
s.syncSharedFolderContents(userID, folderID, cleanMount)
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 {
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
}
cleanType := strings.TrimSpace(targetType)
cleanTarget := strings.TrimSpace(targetIdentifier)
if cleanType == "" {
if strings.HasPrefix(cleanTarget, "team_") {
cleanType = "team"
} else {
cleanType = "user"
}
}
permission = normalizeSharePermission(permission)
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, ErrTargetNotFound
}
} 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, ErrTargetNotFound
}
} 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
}
}
if !s.CanShareWith(currentUserID, cleanType, targetID) {
return nil, ErrTargetNotFound
}
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
}
var ownerMount string
_ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount)
if ownerMount == "" {
ownerMount = f.Name
}
s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount)
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)
} else if cleanType == "team" {
mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID)
if err == nil {
var memberIDs []string
for mRows.Next() {
var uid string
if err := mRows.Scan(&uid); err == nil && uid != "" {
memberIDs = append(memberIDs, uid)
}
}
_ = mRows.Close()
for _, uid := range memberIDs {
_, _ = s.db.Exec(`
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, uid, 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)
// Only the owner manages shares; everyone else can only leave the folder
if !f.IsOwner {
_, 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)
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID)
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
return err
}
// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder.
func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) {
rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
var docIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil && id != "" {
docIDs = append(docIDs, id)
}
}
return docIDs, nil
}