MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
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>
959 lines
29 KiB
Go
959 lines
29 KiB
Go
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
|
|
if *isPinned {
|
|
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO())
|
|
if doc.IsOwner {
|
|
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 1 WHERE id = ?`, docID)
|
|
}
|
|
} else {
|
|
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
|
if doc.IsOwner {
|
|
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 0 WHERE id = ?`, docID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if 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
|
|
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
|
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
|
|
}
|