refactor: split sqlite.go and server.go by domain (FLAW-20)
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>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:14:33 +01:00
1 parent 7bba174454
commit 74c394efaa
26 files changed
+7702 -7467

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
package storage
import (
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- API KEYS & WEBHOOKS ---
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
if strings.TrimSpace(name) == "" {
name = "Smarthome / Webhook Key"
}
rawKey, prefix, hash := auth.GenerateAPIKey()
k := &domain.APIKey{
ID: domain.NewID("key"),
UserID: userID,
Name: strings.TrimSpace(name),
KeyPrefix: prefix,
KeyHash: hash,
RawKey: rawKey,
Scopes: "ingest,read",
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt,
)
if err != nil {
return nil, err
}
return k, nil
}
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
keys := make([]domain.APIKey, 0)
for rows.Next() {
var k domain.APIKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
keys = append(keys, k)
}
}
return keys, nil
}
func (s *Store) DeleteAPIKey(userID, keyID string) error {
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
return err
}
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
var keyID, userID string
err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID)
if err != nil {
return nil, errors.New("invalid API key")
}
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
return s.GetUserByID(userID)
}
+958
View File
@@ -0,0 +1,958 @@
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
}
+862
View File
@@ -0,0 +1,862 @@
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
}
+502
View File
@@ -0,0 +1,502 @@
package storage
import (
"database/sql"
"errors"
"regexp"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- STANDALONE REALTIME LISTS & SHARING ---
func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) {
if strings.TrimSpace(title) == "" {
return nil, errors.New("list title required")
}
if icon == "" {
icon = "🛒"
}
if listType == "" {
listType = "shopping"
}
now := domain.NowISO()
lst := &domain.SharedList{
ID: domain.NewID("lst"),
UserID: userID,
IsOwner: true,
Role: "editor",
Title: strings.TrimSpace(title),
Icon: icon,
ListType: listType,
Items: []domain.ListItem{},
Shares: []domain.ListShare{},
UpdatedAt: now,
CreatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
)
if err != nil {
return nil, err
}
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
return lst, nil
}
func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) {
if targetType != "team" && targetType != "user" {
return nil, errors.New("ungültiger Freigabetyp")
}
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return nil, errors.New("Ziel-ID erforderlich")
}
if role != "viewer" && role != "editor" {
role = "editor"
}
if targetType == "user" {
var targetUser *domain.User
if strings.HasPrefix(targetID, "usr_") {
targetUser, _ = s.GetUserByID(targetID)
}
if targetUser == nil {
targetUser, _ = s.GetUserByLogin(targetID)
}
if targetUser == nil {
targetUser, _ = s.GetUserByEmail(targetID)
}
if targetUser == nil {
return nil, ErrTargetNotFound
}
targetID = targetUser.ID
}
// Only the owner shares
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return nil, errors.New("liste nicht gefunden")
}
if ownerID != currentUserID {
return nil, errors.New("nur der Eigentümer kann die Liste freigeben")
}
if !s.CanShareWith(currentUserID, targetType, targetID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
share := &domain.ListShare{
ID: domain.NewID("lsh"),
ListID: listID,
TargetType: targetType,
TargetID: targetID,
Role: role,
CreatedBy: currentUserID,
CreatedAt: now,
}
_, err = s.db.Exec(
`INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`,
share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt,
)
if err != nil {
return nil, err
}
_ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`,
listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
if targetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
}
return share, nil
}
func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error {
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return errors.New("liste nicht gefunden")
}
if ownerID != currentUserID {
return errors.New("nur der Eigentümer kann Freigaben entfernen")
}
cleanTarget := strings.TrimSpace(shareIDOrTargetID)
_, err = s.db.Exec(
`DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`,
listID, cleanTarget, cleanTarget,
)
return err
}
func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) {
rows, err := s.db.Query(
`SELECT id, list_id, target_type, target_id, role, created_by, created_at
FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`,
listID,
)
if err != nil {
return []domain.ListShare{}, err
}
defer rows.Close()
shares := make([]domain.ListShare, 0)
for rows.Next() {
var sh domain.ListShare
if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
if sh.TargetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email)
}
shares = append(shares, sh)
}
}
return shares, nil
}
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
rows, err := s.db.Query(
`SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name,
CASE
WHEN l.user_id = ? THEN 'owner'
WHEN ls.role IS NOT NULL THEN ls.role
ELSE 'editor'
END as effective_role
FROM lists l
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.user_id = ?
OR ls.id IS NOT NULL
ORDER BY l.updated_at DESC`,
userID, userID, userID, userID,
)
if err != nil {
return nil, err
}
lists := make([]domain.SharedList, 0)
for rows.Next() {
var l domain.SharedList
var ownerName sql.NullString
var effectiveRole string
if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil {
l.OwnerName = ownerName.String
l.IsOwner = (l.UserID == userID)
l.Role = effectiveRole
lists = append(lists, l)
}
}
_ = rows.Close()
for i := range lists {
lists[i].Items, _ = s.GetListItems(lists[i].ID)
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
lists[i].Members = s.getListMembers(lists[i].ID, lists[i].UserID)
}
return lists, nil
}
func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
var l domain.SharedList
var ownerName sql.NullString
var effectiveRole string
var query string
var args []interface{}
if userID == "" {
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name, 'owner' as effective_role
FROM lists l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.id = ?`
args = []interface{}{listID}
} else {
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
u.display_name,
CASE
WHEN l.user_id = ? THEN 'owner'
WHEN ls.role IS NOT NULL THEN ls.role
ELSE 'editor'
END as effective_role
FROM lists l
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.id = ? AND (
l.user_id = ?
OR ls.id IS NOT NULL
)`
args = []interface{}{userID, userID, userID, listID, userID}
}
err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole)
if err != nil {
return nil, err
}
l.OwnerName = ownerName.String
l.IsOwner = (userID == "" || l.UserID == userID)
l.Role = effectiveRole
l.Items, _ = s.GetListItems(l.ID)
l.Shares, _ = s.GetListShares(l.ID)
l.Members = s.getListMembers(l.ID, l.UserID)
return &l, nil
}
// getListMembers returns the owner and everyone a list is shared with (directly or via a team).
func (s *Store) getListMembers(listID, ownerID string) []domain.TaskListMember {
members := make([]domain.TaskListMember, 0)
rows, err := s.db.Query(
`SELECT id, display_name, email, avatar_url FROM users WHERE id IN (
SELECT user_id FROM lists WHERE id = ?
UNION SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'user'
UNION SELECT user_id FROM team_members WHERE team_id IN (SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'team')
) ORDER BY display_name`,
listID, listID, listID,
)
if err != nil {
return members
}
defer rows.Close()
for rows.Next() {
var m domain.TaskListMember
if rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL) == nil {
m.Role = "member"
if m.UserID == ownerID {
m.Role = "owner"
}
members = append(members, m)
}
}
return members
}
func (s *Store) DeleteList(userID, listID string) error {
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return errors.New("liste nicht gefunden")
}
if ownerID != userID {
return errors.New("nur der Eigentümer kann die Liste löschen")
}
_, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
return err
}
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
rows, err := s.db.Query(
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
WHERE li.list_id = ?
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
listID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]domain.ListItem, 0)
for rows.Next() {
var it domain.ListItem
var doneInt int
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
it.IsCompleted = doneInt != 0
items = append(items, it)
}
}
return items, nil
}
func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int, createdBy string) (*domain.ListItem, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("item content cannot be empty")
}
category = strings.TrimSpace(category)
// Auto-extract hashtag if category is empty
if category == "" && strings.Contains(content, "#") {
re := regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
if m := re.FindStringSubmatch(content); len(m) > 1 {
category = m[1]
cleaned := strings.TrimSpace(re.ReplaceAllString(content, ""))
if cleaned != "" {
content = cleaned
}
}
} else if category != "" {
category = strings.TrimPrefix(category, "#")
}
now := domain.NowISO()
it := &domain.ListItem{
ID: domain.NewID("itm"),
ListID: listID,
Content: content,
Category: category,
Quantity: strings.TrimSpace(quantity),
IsCompleted: false,
SortOrder: sortOrder,
CreatedBy: createdBy,
CreatedAt: now,
}
if createdBy != "" {
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName)
}
_, err := s.db.Exec(
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy,
)
if err != nil {
return nil, err
}
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, listID)
return it, nil
}
func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName string) (*domain.ListItem, error) {
now := domain.NowISO()
doneInt := 0
completedAt := ""
completedBy := ""
completedByName := ""
if isCompleted {
doneInt = 1
completedAt = now
completedBy = userID
completedByName = userName
}
_, err := s.db.Exec(
`UPDATE list_items SET is_completed = ?, completed_by = ?, completed_by_name = ?, completed_at = ? WHERE id = ?`,
doneInt, completedBy, completedByName, completedAt, itemID,
)
if err != nil {
return nil, err
}
var it domain.ListItem
var dInt int
err = s.db.QueryRow(
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
FROM list_items WHERE id = ?`,
itemID,
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
if err != nil {
return nil, err
}
it.IsCompleted = dInt != 0
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
return &it, nil
}
func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
now := domain.NowISO()
var it domain.ListItem
var dInt int
err := s.db.QueryRow(
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
FROM list_items WHERE id = ?`,
itemID,
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
if err != nil {
return nil, err
}
it.IsCompleted = dInt != 0
if content != nil {
it.Content = *content
}
if category != nil {
it.Category = *category
}
if quantity != nil {
it.Quantity = *quantity
}
_, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
if err != nil {
return nil, err
}
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
return &it, nil
}
func (s *Store) DeleteListItem(itemID string) (string, error) {
var listID string
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
_, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, itemID)
if listID != "" {
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
}
return listID, err
}
func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
res, err := s.db.Exec(`DELETE FROM list_items WHERE list_id = ? AND is_completed = 1`, listID)
if err != nil {
return 0, err
}
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
return res.RowsAffected()
}
func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, it := range items {
if it.Category != nil && it.Content != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Category, *it.Content, it.ID, listID,
)
} else if it.Category != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Category, it.ID, listID,
)
} else if it.Content != nil {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, *it.Content, it.ID, listID,
)
} else {
_, err = tx.Exec(
`UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`,
it.SortOrder, it.ID, listID,
)
}
if err != nil {
return err
}
}
_, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
return tx.Commit()
}
File diff suppressed because it is too large. Load diff
+208
View File
@@ -0,0 +1,208 @@
package storage
import (
"encoding/json"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- STATS, SEARCH & EXPORT ---
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
st := &domain.DashboardStats{}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount)
// Same tasks as GetTasks without a list ("Alle Aufgaben"): own ones plus those in shared task lists
taskScope := `(user_id = ? OR task_list_id IN (` + s.accessibleTaskListIDsQuery() + `)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 0`, userID, userID, userID, userID).Scan(&st.TasksOpenCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 1`, userID, userID, userID, userID).Scan(&st.TasksDoneCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
_ = s.db.QueryRow(`
SELECT COUNT(DISTINCT l.id)
FROM lists l
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
(ls.target_type = 'user' AND ls.target_id = ?)
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
WHERE l.user_id = ? OR ls.id IS NOT NULL`,
userID, userID, userID).Scan(&st.ListsCount)
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
docRows, err := s.db.Query(`
SELECT blocks_json FROM documents
WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?))
AND is_archived = 0`,
userID, userID,
)
if err == nil {
for docRows.Next() {
var bj string
if err := docRows.Scan(&bj); err == nil && bj != "" {
var blocks []domain.DocumentBlock
if err := json.Unmarshal([]byte(bj), &blocks); err == nil {
for _, b := range blocks {
if b.Type == "todo" && !b.Checked {
st.DocsTodosOpenCount++
}
}
}
}
}
_ = docRows.Close()
}
return st, nil
}
func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
res, err := s.SearchGlobal(userID, query)
if err != nil {
return nil, nil, err
}
return res.Thoughts, res.Documents, nil
}
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
user, err := s.GetUserByID(userID)
if err != nil {
return nil, err
}
thoughts, _ := s.GetThoughts(userID, "all", 5000)
docs, _ := s.GetDocuments(userID)
lists, _ := s.GetAllListsForUser(userID)
taskLists, _ := s.GetTaskLists(userID)
return &domain.FullExport{
ExportedAt: domain.NowISO(),
Version: "1.0.0",
User: *user,
Thoughts: thoughts,
Documents: docs,
Lists: lists,
TaskLists: taskLists,
}, nil
}
// --- SYSTEM SETTINGS ---
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls, skipVerify bool) {
cur, err := s.GetSystemSettings()
if err != nil || cur == nil {
return
}
// Only populate DB from env if DB has no SMTP host configured yet and env provides one
if cur.SMTPHost == "" && strings.TrimSpace(host) != "" {
cur.SMTPHost = strings.TrimSpace(host)
if strings.TrimSpace(port) != "" {
cur.SMTPPort = strings.TrimSpace(port)
}
cur.SMTPUser = strings.TrimSpace(user)
cur.SMTPPass = pass
cur.SMTPFrom = strings.TrimSpace(from)
cur.SMTPTLS = tls
cur.SMTPSkipVerify = skipVerify
_ = s.UpdateSystemSettings(cur)
}
}
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
settings := &domain.SystemSettings{
SharingMode: SharingOrganizations,
AllowRegistration: false,
InstanceName: "MindSift",
SMTPPort: "587",
SMTPTLS: true,
}
rows, err := s.db.Query(`SELECT key, value FROM system_settings`)
if err != nil {
return settings, nil
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err == nil {
switch k {
case "sharing_mode":
settings.SharingMode = v
case "allow_registration":
settings.AllowRegistration = (v == "true" || v == "1")
case "instance_name":
settings.InstanceName = v
case "smtp_host":
settings.SMTPHost = v
case "smtp_port":
if v != "" {
settings.SMTPPort = v
}
case "smtp_user":
settings.SMTPUser = v
case "smtp_pass":
settings.SMTPPass = v
case "smtp_from":
settings.SMTPFrom = v
case "smtp_tls":
settings.SMTPTLS = (v != "false" && v != "0")
case "smtp_skip_verify":
settings.SMTPSkipVerify = v == "true"
case "allow_private_webhooks":
settings.AllowPrivateWebhooks = v == "true"
}
}
}
return settings, nil
}
func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
regStr := "false"
if settings.AllowRegistration {
regStr = "true"
}
tlsStr := "false"
if settings.SMTPTLS {
tlsStr = "true"
}
skipVerifyStr := "false"
if settings.SMTPSkipVerify {
skipVerifyStr = "true"
}
privateHooksStr := "false"
if settings.AllowPrivateWebhooks {
privateHooksStr = "true"
}
if settings.SMTPPort == "" {
settings.SMTPPort = "587"
}
settings.SharingMode = NormalizeSharingMode(settings.SharingMode)
if settings.InstanceName == "" {
settings.InstanceName = "MindSift"
}
_, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES
('sharing_mode', ?),
('allow_registration', ?),
('instance_name', ?),
('smtp_host', ?),
('smtp_port', ?),
('smtp_user', ?),
('smtp_pass', ?),
('smtp_from', ?),
('smtp_tls', ?),
('smtp_skip_verify', ?),
('allow_private_webhooks', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
settings.SharingMode,
regStr,
settings.InstanceName,
strings.TrimSpace(settings.SMTPHost),
strings.TrimSpace(settings.SMTPPort),
strings.TrimSpace(settings.SMTPUser),
settings.SMTPPass,
strings.TrimSpace(settings.SMTPFrom),
tlsStr,
skipVerifyStr,
privateHooksStr,
)
return err
}
+242
View File
@@ -0,0 +1,242 @@
package storage
import (
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
if cleanTag == "" {
return "", errors.New("tag name required")
}
now := domain.NowISO()
_, err := s.db.Exec(
`INSERT INTO tag_pages (user_id, tag, content, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, tag) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
userID, cleanTag, content, now,
)
if err != nil {
return "", err
}
return now, nil
}
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
rows, err := s.db.Query(
`SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count
FROM block_tags t
JOIN documents d ON t.document_id = d.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
LEFT JOIN (
SELECT dts2.doc_id
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
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
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
AND d.is_archived = 0
GROUP BY t.tag
ORDER BY count DESC, t.tag ASC`,
userID, userID, userID, userID, userID, userID, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
results := make([]domain.TagSummary, 0)
seen := make(map[string]bool)
for rows.Next() {
var ts domain.TagSummary
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
results = append(results, ts)
seen[ts.Tag] = true
}
}
_ = rows.Close()
// Also include tags that have a non-empty tag_pages note even if not currently used in a block
pageRows, err := s.db.Query(
`SELECT tag FROM tag_pages WHERE user_id = ? AND TRIM(content) != '' ORDER BY tag ASC`,
userID,
)
if err == nil {
for pageRows.Next() {
var t string
if err := pageRows.Scan(&t); err == nil && t != "" && !seen[t] {
results = append(results, domain.TagSummary{Tag: t, Count: 0})
seen[t] = true
}
}
_ = pageRows.Close()
}
return results, nil
}
func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
if cleanTag == "" {
return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil
}
var noteContent, noteUpdatedAt string
_ = s.db.QueryRow(
`SELECT content, updated_at FROM tag_pages WHERE user_id = ? AND tag = ?`,
userID, cleanTag,
).Scan(&noteContent, &noteUpdatedAt)
rows, err := s.db.Query(
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
COALESCE(u.display_name, '') as owner_name,
COALESCE(s.permission, dts.permission, wfs.permission, '') as shared_permission
FROM block_tags t
JOIN documents d ON t.document_id = d.id
LEFT JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON s.doc_id = d.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
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
AND t.tag = ?
AND d.is_archived = 0
ORDER BY d.updated_at DESC`,
userID, userID, userID, userID, userID, userID, userID, cleanTag,
)
if err != nil {
return nil, err
}
defer rows.Close()
type rawMatch struct {
blockID string
docID string
title string
icon string
folderPath string
blocksJSON string
updatedAt string
docUserID string
ownerName string
sharedPermission string
}
var rawMatches []rawMatch
for rows.Next() {
var rm rawMatch
if err := rows.Scan(&rm.blockID, &rm.docID, &rm.title, &rm.icon, &rm.folderPath, &rm.blocksJSON, &rm.updatedAt, &rm.docUserID, &rm.ownerName, &rm.sharedPermission); err == nil {
rawMatches = append(rawMatches, rm)
}
}
_ = rows.Close()
// Group rawMatches by docID
docMap := make(map[string][]rawMatch)
var docOrder []string
for _, rm := range rawMatches {
if _, exists := docMap[rm.docID]; !exists {
docOrder = append(docOrder, rm.docID)
}
docMap[rm.docID] = append(docMap[rm.docID], rm)
}
totalMatches := 0
docGroups := make([]domain.TagDocumentGroup, 0, len(docOrder))
for _, docID := range docOrder {
matches := docMap[docID]
first := matches[0]
blocks := s.loadDocBlocks(first.docID, first.blocksJSON)
blockIdxMap := make(map[string]int, len(blocks))
for i, b := range blocks {
blockIdxMap[b.ID] = i
}
matchedBlocks := make([]domain.MatchedTagBlock, 0)
processedBlockIDs := make(map[string]bool)
for _, m := range matches {
if processedBlockIDs[m.blockID] {
continue
}
idx, ok := blockIdxMap[m.blockID]
if !ok {
continue
}
rootBlock := blocks[idx]
processedBlockIDs[rootBlock.ID] = true
children := make([]domain.DocumentBlock, 0)
rootLevel := rootBlock.Level
for i := idx + 1; i < len(blocks); i++ {
if blocks[i].Level > rootLevel {
children = append(children, blocks[i])
processedBlockIDs[blocks[i].ID] = true
} else {
break
}
}
matchedBlocks = append(matchedBlocks, domain.MatchedTagBlock{
RootBlock: rootBlock,
Children: children,
})
totalMatches++
}
if len(matchedBlocks) == 0 {
continue
}
isOwner := first.docUserID == userID
permission := "read"
if isOwner {
permission = "owner"
} else if first.sharedPermission != "" {
permission = first.sharedPermission
}
docGroups = append(docGroups, domain.TagDocumentGroup{
DocID: first.docID,
Title: first.title,
Icon: first.icon,
FolderPath: first.folderPath,
UpdatedAt: first.updatedAt,
IsOwner: isOwner,
Permission: permission,
OwnerName: first.ownerName,
MatchedBlocks: matchedBlocks,
})
}
return &domain.TagDetails{
Tag: cleanTag,
NoteContent: noteContent,
NoteUpdatedAt: noteUpdatedAt,
Documents: docGroups,
TotalMatches: totalMatches,
}, nil
}
+541
View File
@@ -0,0 +1,541 @@
package storage
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- TASK LISTS ---
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string, columns ...[]domain.KanbanColumn) (*domain.TaskList, error) {
if strings.TrimSpace(title) == "" {
title = "Meine Aufgaben"
}
if icon == "" {
icon = "📋"
}
if color == "" {
color = "#2563eb"
}
cols := []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
if len(columns) > 0 && len(columns[0]) > 0 {
cols = columns[0]
}
colsJSON, _ := json.Marshal(cols)
now := domain.NowISO()
tl := &domain.TaskList{
ID: domain.NewID("tkl"),
UserID: userID,
SpaceID: spaceID,
Title: strings.TrimSpace(title),
Icon: icon,
Color: color,
Columns: cols,
CreatedAt: now,
UpdatedAt: now,
IsOwner: true,
}
_, err := s.db.Exec(
`INSERT INTO task_lists (id, user_id, space_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
)
if err != nil {
return nil, err
}
if teamID != "" && s.CanShareWith(userID, "team", teamID) {
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
}
return s.GetTaskListByID(userID, tl.ID)
}
// ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks.
var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)")
// ErrAssigneeNoAccess is returned when a task should be assigned to someone who cannot see its list.
var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff auf die Liste zugewiesen werden")
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
func (s *Store) GetTaskListPermission(userID, listID string) string {
var ownerID string
if err := s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&ownerID); err != nil {
return ""
}
if ownerID == userID {
return "owner"
}
rows, err := s.db.Query(
`SELECT permission FROM task_list_shares
WHERE task_list_id = ? AND (
(target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)`,
listID, userID, userID,
)
perm := ""
if err == nil {
for rows.Next() {
var p string
if rows.Scan(&p) == nil {
if p == "viewer" {
if perm == "" {
perm = "viewer"
}
} else {
perm = "editor"
}
}
}
_ = rows.Close()
}
return perm
}
func (s *Store) CanEditTaskList(userID, listID string) bool {
p := s.GetTaskListPermission(userID, listID)
return p == "owner" || p == "editor"
}
// ensureTaskListWritable returns ErrTaskListReadOnly if the list is set and the user may not modify it.
func (s *Store) ensureTaskListWritable(userID, listID string) error {
if listID == "" || s.CanEditTaskList(userID, listID) {
return nil
}
return ErrTaskListReadOnly
}
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, createdBy string) (*domain.TaskListShare, error) {
if targetType != "team" && targetType != "user" {
return nil, errors.New("ungültiger Freigabetyp")
}
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return nil, errors.New("Ziel-ID erforderlich")
}
if permission != "viewer" {
permission = "editor"
}
if !s.CanShareWith(createdBy, targetType, targetID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
share := &domain.TaskListShare{
ID: domain.NewID("tls"),
TaskListID: taskListID,
TargetType: targetType,
TargetID: targetID,
Permission: permission,
CreatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, permission, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at, permission = excluded.permission`,
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.Permission, share.CreatedAt, createdBy,
)
if err != nil {
return nil, err
}
_ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`,
taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
if targetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
}
return share, nil
}
func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error {
_, err := s.db.Exec(
`DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`,
taskListID, shareIDOrTargetID, shareIDOrTargetID,
)
s.PruneTaskAssignees()
return err
}
func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) {
rows, err := s.db.Query(
`SELECT id, task_list_id, target_type, target_id, permission, created_at
FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`,
taskListID,
)
if err != nil {
return []domain.TaskListShare{}, err
}
defer rows.Close()
shares := make([]domain.TaskListShare, 0)
for rows.Next() {
var sh domain.TaskListShare
if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.Permission, &sh.CreatedAt); err == nil {
shares = append(shares, sh)
}
}
for i := range shares {
if shares[i].TargetType == "team" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email, &shares[i].AvatarURL)
}
}
return shares, nil
}
// queryUserIDs runs a query returning a single user_id column and collects the distinct IDs.
func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
rows, err := s.db.Query(query, args...)
if err != nil {
return []string{}
}
defer rows.Close()
ids := make([]string, 0)
seen := make(map[string]bool)
for rows.Next() {
var id string
if rows.Scan(&id) == nil && id != "" && !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
return ids
}
// taskListAccessSQL selects every user ID with access to a task list: owner, direct shares and members of
// shared groups. %[1]s is the list ID expression ("?" or a correlated column).
const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')`
func (s *Store) getTaskAssignees(taskID string) []string {
ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID)
return ids
}
// attachTaskAssignees fills Assignees for a batch of tasks with a single query.
func (s *Store) attachTaskAssignees(tasks []domain.Thought) {
if len(tasks) == 0 {
return
}
idx := make(map[string]int, len(tasks))
placeholders := make([]string, 0, len(tasks))
args := make([]interface{}, 0, len(tasks))
for i := range tasks {
idx[tasks[i].ID] = i
placeholders = append(placeholders, "?")
args = append(args, tasks[i].ID)
}
rows, err := s.db.Query(
`SELECT task_id, user_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY created_at ASC`,
args...,
)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var taskID, uid string
if rows.Scan(&taskID, &uid) == nil {
if i, ok := idx[taskID]; ok {
tasks[i].Assignees = append(tasks[i].Assignees, uid)
}
}
}
}
// SetTaskAssignees replaces the assignees of a task. Only editors of the task's list may assign, and only
// people who can see the list are accepted.
func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]string, error) {
thought, err := s.GetThoughtByID(userID, taskID)
if err != nil {
return nil, err
}
if thought.TaskListID == "" {
if len(assignees) > 0 {
return nil, ErrAssigneeNoAccess
}
return []string{}, nil
}
if !s.CanEditTaskList(userID, thought.TaskListID) {
return nil, ErrTaskListReadOnly
}
allowed := make(map[string]bool)
for _, id := range s.GetTaskListUserIDs(thought.TaskListID) {
allowed[id] = true
}
clean := make([]string, 0, len(assignees))
seen := make(map[string]bool)
for _, id := range assignees {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
if !allowed[id] {
return nil, ErrAssigneeNoAccess
}
seen[id] = true
clean = append(clean, id)
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
keep := make([]interface{}, 0, len(clean)+1)
keep = append(keep, taskID)
ph := make([]string, 0, len(clean))
for _, id := range clean {
keep = append(keep, id)
ph = append(ph, "?")
}
delQuery := `DELETE FROM task_assignees WHERE task_id = ?`
if len(ph) > 0 {
delQuery += ` AND user_id NOT IN (` + strings.Join(ph, ",") + `)`
}
if _, err := tx.Exec(delQuery, keep...); err != nil {
return nil, err
}
now := domain.NowISO()
for _, id := range clean {
if _, err := tx.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, id, now); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.getTaskAssignees(taskID), nil
}
// PruneTaskAssignees drops every assignment whose user can no longer see the task's list (share removed,
// left the team, team deleted, task moved to another list or out of any list). Called after each change
// that can take access away, so assignments never outlive permissions.
func (s *Store) PruneTaskAssignees() {
_, _ = s.db.Exec(
`DELETE FROM task_assignees WHERE rowid IN (
SELECT ta.rowid FROM task_assignees ta
LEFT JOIN thoughts t ON t.id = ta.task_id
WHERE t.id IS NULL OR t.task_list_id = ''
OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_id") + `)
)`,
)
}
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares and members of shared teams.
func (s *Store) GetTaskListUserIDs(listID string) []string {
if listID == "" {
return []string{}
}
return s.queryUserIDs(fmt.Sprintf(taskListAccessSQL, "?"), listID, listID, listID)
}
// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with.
func (s *Store) GetSharedFolderUserIDs(folderID string) []string {
if folderID == "" {
return []string{}
}
return s.queryUserIDs(
`SELECT owner_id FROM workspace_shared_folders WHERE id = ?
UNION SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'team')`,
folderID, folderID, folderID,
)
}
func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember {
memberMap := make(map[string]domain.TaskListMember)
// 1. Owner
var ownerName, ownerEmail, ownerAvatar string
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar)
memberMap[tl.UserID] = domain.TaskListMember{
UserID: tl.UserID,
DisplayName: ownerName,
Email: ownerEmail,
AvatarURL: ownerAvatar,
Role: "owner",
}
// 2. From task_list_shares
for _, sh := range tl.Shares {
if sh.TargetType == "team" {
rows, err := s.db.Query(
`SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
sh.TargetID,
)
if err == nil {
for rows.Next() {
var m domain.TaskListMember
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
m.Role = "member"
memberMap[m.UserID] = m
}
}
_ = rows.Close()
}
} else if sh.TargetType == "user" {
var m domain.TaskListMember
if err := s.db.QueryRow(`SELECT id, display_name, email, avatar_url FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
m.Role = "member"
memberMap[m.UserID] = m
}
}
}
result := make([]domain.TaskListMember, 0, len(memberMap))
for _, m := range memberMap {
result = append(result, m)
}
return result
}
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
rows, err := s.db.Query(
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
FROM task_lists
WHERE user_id = ?
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
ORDER BY updated_at DESC`,
userID, userID, userID,
)
if err != nil {
return []domain.TaskList{}, err
}
defaultCols := []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
list := make([]domain.TaskList, 0)
for rows.Next() {
var tl domain.TaskList
var colsJSON string
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
tl.IsOwner = (tl.UserID == userID)
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
if len(tl.Columns) == 0 {
tl.Columns = defaultCols
}
list = append(list, tl)
}
}
_ = rows.Close()
for i := range list {
list[i].Members = make([]domain.TaskListMember, 0)
list[i].Shares = make([]domain.TaskListShare, 0)
_ = s.db.QueryRow(
`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`,
list[i].ID,
).Scan(&list[i].TasksCount)
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName)
shares, _ := s.GetTaskListShares(list[i].ID)
list[i].Shares = shares
list[i].Permission = s.GetTaskListPermission(userID, list[i].ID)
list[i].Members = s.getMembersForTaskList(&list[i])
}
return list, nil
}
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
var tl domain.TaskList
var colsJSON string
err := s.db.QueryRow(
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
FROM task_lists
WHERE id = ? AND (
user_id = ?
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
)`,
listID, userID, userID, userID,
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
if err != nil {
return nil, err
}
tl.IsOwner = (tl.UserID == userID)
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
if len(tl.Columns) == 0 {
tl.Columns = []domain.KanbanColumn{
{ID: "todo", Name: "Zu erledigen"},
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
}
}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
shares, _ := s.GetTaskListShares(tl.ID)
tl.Shares = shares
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
tl.Members = s.getMembersForTaskList(&tl)
return &tl, nil
}
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
tl, err := s.GetTaskListByID(userID, listID)
if err != nil {
return nil, err
}
if tl.Permission == "viewer" {
return nil, ErrTaskListReadOnly
}
if title != nil {
tl.Title = strings.TrimSpace(*title)
}
if icon != nil && *icon != "" {
tl.Icon = *icon
}
if color != nil && *color != "" {
tl.Color = *color
}
if spaceID != nil {
tl.SpaceID = *spaceID
}
if columns != nil {
tl.Columns = *columns
}
tl.UpdatedAt = domain.NowISO()
colsJSON, _ := json.Marshal(tl.Columns)
_, err = s.db.Exec(
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
tl.Title, tl.Icon, tl.Color, tl.SpaceID, string(colsJSON), tl.UpdatedAt, listID,
)
if err != nil {
return nil, err
}
return s.GetTaskListByID(userID, listID)
}
func (s *Store) DeleteTaskList(userID, listID string) error {
_, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID)
_, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID)
_, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID)
s.PruneTaskAssignees()
return err
}
+247
View File
@@ -0,0 +1,247 @@
package storage
import (
"database/sql"
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- ORGANIZATIONS & TEAMS ---
func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("team name required")
}
orgID = strings.TrimSpace(orgID)
team := &domain.Team{
ID: domain.NewID("team"),
OrgID: orgID,
Name: name,
Description: strings.TrimSpace(description),
Members: []domain.TeamMember{},
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
)
if err != nil {
return nil, err
}
return team, nil
}
func (s *Store) DeleteTeam(teamID string) error {
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
s.pruneSharingNow()
return err
}
func (s *Store) GetAllTeams() ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT id, org_id, name, description, created_at FROM teams ORDER BY name COLLATE NOCASE ASC`,
)
if err != nil {
return []domain.Team{}, err
}
defer rows.Close()
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`,
orgID,
)
if err != nil {
return []domain.Team{}, err
}
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
_ = rows.Close()
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
func (s *Store) GetTeamByID(teamID string) (*domain.Team, error) {
var t domain.Team
err := s.db.QueryRow(
`SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`,
teamID,
).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt)
if err != nil {
return nil, err
}
t.Members, _ = s.GetTeamMembers(t.ID)
return &t, nil
}
func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
FROM teams t
JOIN team_members tm ON tm.team_id = t.id
WHERE tm.user_id = ?
ORDER BY t.created_at ASC`,
userID,
)
if err != nil {
return []domain.Team{}, err
}
teams := make([]domain.Team, 0)
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
t.Members = []domain.TeamMember{}
teams = append(teams, t)
}
}
_ = rows.Close()
for i := range teams {
members, _ := s.GetTeamMembers(teams[i].ID)
teams[i].Members = members
}
return teams, nil
}
func (s *Store) UpdateTeam(teamID, name, description string) (*domain.Team, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("Name für die Gruppe ist erforderlich")
}
_, err := s.db.Exec(`UPDATE teams SET name = ?, description = ? WHERE id = ?`, name, strings.TrimSpace(description), teamID)
if err != nil {
return nil, err
}
return s.GetTeamByID(teamID)
}
func (s *Store) GetTeamMemberRole(teamID, userID string) (string, error) {
var role string
err := s.db.QueryRow(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return role, nil
}
func (s *Store) CountTeamAdmins(teamID string) (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND role = 'admin'`, teamID).Scan(&count)
return count, err
}
func (s *Store) AddTeamMember(teamID, userID, role string) error {
role = strings.ToLower(strings.TrimSpace(role))
if role != "admin" && role != "member" {
role = "member"
}
_, err := s.db.Exec(
`INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
ON CONFLICT(team_id, user_id) DO UPDATE SET role = excluded.role`,
teamID, userID, role, domain.NowISO(),
)
return err
}
func (s *Store) UpdateTeamMemberRole(teamID, userID, role string) error {
role = strings.ToLower(strings.TrimSpace(role))
if role != "admin" && role != "member" {
return errors.New("Ungültige Rolle (erlaubt sind: admin, member)")
}
currentRole, err := s.GetTeamMemberRole(teamID, userID)
if err != nil {
return err
}
if currentRole == "" {
return errors.New("Benutzer ist kein Mitglied dieser Gruppe")
}
if currentRole == "admin" && role != "admin" {
adminCount, err := s.CountTeamAdmins(teamID)
if err != nil {
return err
}
if adminCount <= 1 {
return errors.New("Die Gruppe muss mindestens einen Gruppen-Admin behalten")
}
}
_, err = s.db.Exec(`UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?`, role, teamID, userID)
return err
}
func (s *Store) RemoveTeamMember(teamID, userID string) error {
currentRole, err := s.GetTeamMemberRole(teamID, userID)
if err != nil {
return err
}
if currentRole == "admin" {
adminCount, err := s.CountTeamAdmins(teamID)
if err != nil {
return err
}
if adminCount <= 1 {
return errors.New("Du bist der letzte Gruppen-Admin. Bitte ernenne zuerst einen anderen Admin oder lösche die Gruppe.")
}
}
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
// Whoever leaves a group also loses the shares they made with it
s.pruneSharingNow()
return err
}
func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
rows, err := s.db.Query(
`SELECT tm.team_id, tm.user_id, u.display_name, u.email, u.avatar_url, tm.role, tm.joined_at
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = ?
ORDER BY tm.joined_at ASC`,
teamID,
)
if err != nil {
return []domain.TeamMember{}, err
}
defer rows.Close()
members := make([]domain.TeamMember, 0)
for rows.Next() {
var m domain.TeamMember
if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL, &m.Role, &m.JoinedAt); err == nil {
members = append(members, m)
}
}
return members, nil
}
+639
View File
@@ -0,0 +1,639 @@
package storage
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- THOUGHTS & TRIAGE ---
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
return s.CreateThoughtWithDetails(userID, content, priority, source, dueDate, "", "todo", "")
}
func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) {
content = strings.TrimSpace(content)
dueDate = domain.NormalizeDue(dueDate)
if content == "" {
return nil, errors.New("content cannot be empty")
}
if priority == "" {
priority = "normal"
}
if source == "" {
source = "manual"
}
if kanbanStatus == "" {
kanbanStatus = "todo"
}
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
tags := domain.ExtractTags(content)
if strings.EqualFold(source, "voice") {
hasDiktat := false
for _, tag := range tags {
if strings.EqualFold(tag, "diktat") {
hasDiktat = true
break
}
}
if !hasDiktat {
tags = append(tags, "diktat")
}
}
if strings.EqualFold(source, "webhook") {
hasWebhook := false
for _, tag := range tags {
if strings.EqualFold(tag, "webhook") {
hasWebhook = true
break
}
}
if !hasWebhook {
tags = append(tags, "webhook")
}
}
tagsJSON, _ := json.Marshal(tags)
t := &domain.Thought{
ID: domain.NewID("th"),
UserID: userID,
Content: content,
Tags: tags,
Priority: priority,
Source: source,
Status: domain.StatusInbox,
DueDate: dueDate,
TaskListID: taskListID,
KanbanStatus: kanbanStatus,
Notes: notes,
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
)
if err != nil {
return nil, err
}
t.Subtasks = []domain.SubTask{}
return t, nil
}
func scanThought(rows interface {
Scan(dest ...any) error
}) (*domain.Thought, error) {
t := &domain.Thought{}
var tagsStr, subtasksStr, statusStr string
var isCompleted, alarmSent, sortOrder int
err := rows.Scan(
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
)
if err != nil {
return nil, err
}
t.SortOrder = sortOrder
t.Status = domain.ThoughtStatus(statusStr)
t.IsCompleted = isCompleted != 0
t.AlarmSent = alarmSent != 0
if t.KanbanStatus == "" {
if t.IsCompleted {
t.KanbanStatus = "done"
} else {
t.KanbanStatus = "todo"
}
}
t.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
t.DocID = parts[0]
t.BlockID = parts[1]
}
t.Assignees = []string{}
t.Subtasks = []domain.SubTask{}
if subtasksStr != "" && subtasksStr != "null" {
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
}
return t, nil
}
func (s *Store) accessibleTaskListIDsQuery() string {
return `SELECT id FROM task_lists
WHERE user_id = ?
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)`
}
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := s.db.QueryRow(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID,
)
t, err := scanThought(row)
if err != nil {
return nil, err
}
t.Assignees = s.getTaskAssignees(t.ID)
return t, nil
}
func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
var rows *sql.Rows
var err error
if statusFilter != "" && statusFilter != "all" {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE user_id = ? AND status = ?
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
userID, statusFilter, limit,
)
} else {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE user_id = ?
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
userID, limit,
)
}
if err != nil {
return nil, err
}
result := make([]domain.Thought, 0)
for rows.Next() {
t, err := scanThought(rows)
if err == nil {
result = append(result, *t)
}
}
_ = rows.Close()
return result, nil
}
func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, error) {
var rows *sql.Rows
var err error
if taskListID != "" {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND task_list_id = ?
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, taskListID,
)
} else {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID,
)
}
if err != nil {
return nil, err
}
result := make([]domain.Thought, 0)
for rows.Next() {
t, err := scanThought(rows)
if err == nil {
result = append(result, *t)
}
}
_ = rows.Close()
s.attachTaskAssignees(result)
return result, nil
}
func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
for _, it := range items {
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
isCompleted := 0
if *it.KanbanStatus == "done" {
isCompleted = 1
var was int
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
if was == 0 {
completed = append(completed, it.ID)
}
}
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID,
)
} else {
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ? WHERE id = ?`,
it.SortOrder, it.ID,
)
}
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
for _, id := range completed {
s.spawnNextOccurrence(id)
}
return nil
}
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return nil, err
}
now := domain.NowISO()
newStatus := domain.StatusKept
convertedType := thought.ConvertedType
convertedRefID := thought.ConvertedRefID
dueDate = domain.NormalizeDue(dueDate)
if dueDate == "" {
dueDate = thought.DueDate
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID == "" {
taskListID = thought.TaskListID
} else if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
switch action {
case "discard":
newStatus = domain.StatusDiscarded
case "restore":
newStatus = domain.StatusInbox
case "keep":
newStatus = domain.StatusKept
if convertTo == "task" {
newStatus = domain.StatusConverted
convertedType = "task"
} else if convertTo == "document" {
newStatus = domain.StatusConverted
convertedType = "document"
title := thought.Content
if len(title) > 72 {
title = title[:72] + "…"
}
blocks := make([]domain.DocumentBlock, 0)
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "todo",
Content: thought.Content,
Checked: thought.IsCompleted,
Level: 0,
})
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
if strings.TrimSpace(thought.Notes) != "" {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.TrimSpace(thought.Notes),
Level: 1,
})
}
if len(thought.Subtasks) > 0 {
for _, st := range thought.Subtasks {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "todo",
Content: st.Title,
Checked: st.IsCompleted,
Level: 1,
})
}
}
var metaItems []string
if thought.DueDate != "" {
metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate))
}
if thought.Priority != "" && thought.Priority != "normal" {
metaItems = append(metaItems, "Priorität: "+thought.Priority)
}
if len(metaItems) > 0 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.Join(metaItems, " • "),
Level: 1,
})
}
if len(blocks) == 1 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: "",
Level: 0,
})
}
doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false)
if err == nil && doc != nil {
convertedRefID = doc.ID
}
} else if convertTo == "list_item" && targetListID != "" {
newStatus = domain.StatusConverted
convertedType = "list_item"
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
if err == nil && item != nil {
convertedRefID = item.ID
}
}
default:
return nil, fmt.Errorf("invalid triage action: %s", action)
}
_, err = s.db.Exec(
`UPDATE thoughts
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?,
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END
WHERE id = ? AND user_id = ?`,
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID,
)
if err != nil {
return nil, err
}
if taskListID != thought.TaskListID {
s.PruneTaskAssignees()
}
return s.GetThoughtByID(userID, thoughtID)
}
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask, tags *[]string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return nil, err
}
wasCompleted := thought.IsCompleted
oldDue := thought.DueDate
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID != nil && *taskListID != thought.TaskListID {
if err := s.ensureTaskListWritable(userID, *taskListID); err != nil {
return nil, err
}
}
if content != nil {
thought.Content = strings.TrimSpace(*content)
thought.Tags = domain.ExtractTags(thought.Content)
}
if tags != nil {
thought.Tags = *tags
}
if isCompleted != nil {
thought.IsCompleted = *isCompleted
if *isCompleted {
thought.KanbanStatus = "done"
} else if thought.KanbanStatus == "done" {
thought.KanbanStatus = "todo"
}
}
if dueDate != nil {
thought.DueDate = domain.NormalizeDue(*dueDate)
}
if priority != nil {
p := strings.ToLower(strings.TrimSpace(*priority))
if p == "" || p == "none" {
p = "normal"
}
thought.Priority = p
}
if taskListID != nil {
thought.TaskListID = *taskListID
}
if kanbanStatus != nil && *kanbanStatus != "" {
thought.KanbanStatus = *kanbanStatus
if isCompleted == nil {
if *kanbanStatus == "done" {
thought.IsCompleted = true
} else {
thought.IsCompleted = false
}
}
}
if notes != nil {
thought.Notes = *notes
}
if subtasks != nil {
thought.Subtasks = *subtasks
}
tagsJSON, _ := json.Marshal(thought.Tags)
subtasksJSON, _ := json.Marshal(thought.Subtasks)
completedInt := 0
if thought.IsCompleted {
completedInt = 1
}
_, err = s.db.Exec(
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`,
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID,
)
if err != nil {
return nil, err
}
// A new due date or a reopened task gets its alarm again
if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) {
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID)
thought.AlarmSent = false
}
if taskListID != nil {
s.PruneTaskAssignees()
thought.Assignees = s.getTaskAssignees(thoughtID)
}
if !wasCompleted && thought.IsCompleted {
s.spawnNextOccurrence(thoughtID)
}
return thought, nil
}
func (s *Store) DeleteThought(userID, thoughtID string) error {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return err
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return err
}
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
return err
}
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
// Linking changes the block (its tick follows the task), so edit rights are needed
if !CanEditDoc(s.DocRole(userID, docID)) {
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
}
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
if err != nil {
return nil, err
}
t.ConvertedType = "document_block"
t.ConvertedRefID = docID + ":" + blockID
t.DocID = docID
t.BlockID = blockID
t.IsCompleted = checked
t.Status = domain.StatusKept
doneInt := 0
if checked {
doneInt = 1
t.KanbanStatus = "done"
}
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
s.SetBlockChecked(userID, docID, blockID, checked)
return t, nil
}
// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened).
// Returns the note ID if the block changed, so the caller can notify its viewers.
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string {
t, err := s.GetThoughtByID(userID, taskID)
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
return ""
}
docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":")
if s.SetBlockChecked(userID, docID, blockID, isCompleted) {
return docID
}
return ""
}
// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's
// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change.
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil {
return
}
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID)
}
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
if taskID == "" {
return
}
doneInt := 0
kanban := "todo"
if checked {
doneInt = 1
kanban = "done"
}
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt)
if checked && res != nil {
if n, _ := res.RowsAffected(); n > 0 {
s.spawnNextOccurrence(taskID)
}
}
}
// --- ALARMS ---
type DueTaskAlarm struct {
TaskID string
UserID string
Title string
Notes string
DueDate string
Priority string
UserEmail string
AlarmChannels []string
AlarmEmail string
DiscordWebhookURL string
CustomWebhookURL string
NotifyBeforeMinutes int
}
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
rows, err := s.db.Query(
`SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority,
u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes
FROM thoughts t
JOIN users u ON u.id = t.user_id
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
AND t.is_completed = 0
AND t.alarm_sent = 0
AND t.due_date != ''
AND u.is_active = 1`,
)
if err != nil {
return []DueTaskAlarm{}, err
}
defer rows.Close()
alarms := make([]DueTaskAlarm, 0)
for rows.Next() {
var a DueTaskAlarm
var channelsRaw string
if err := rows.Scan(
&a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority,
&a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes,
); err == nil {
a.AlarmChannels = parseAlarmChannels(channelsRaw)
if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail
}
alarms = append(alarms, a)
}
}
return alarms, nil
}
func (s *Store) MarkTaskAlarmSent(taskID string) error {
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
return err
}
+413
View File
@@ -0,0 +1,413 @@
package storage
import (
"errors"
"fmt"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- USERS ---
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, organization_id, created_at`
func parseAlarmChannels(raw string) []string {
parts := strings.Split(raw, ",")
res := make([]string, 0, len(parts))
seen := make(map[string]bool)
for _, p := range parts {
p = strings.ToLower(strings.TrimSpace(p))
if (p == "email" || p == "discord" || p == "webhook") && !seen[p] {
seen[p] = true
res = append(res, p)
}
}
return res
}
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
u := &domain.User{}
var isActiveInt int
var channelsRaw string
err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
&u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.OrganizationID, &u.CreatedAt,
)
if err != nil {
return nil, err
}
u.IsActive = isActiveInt != 0
if u.TokenVersion <= 0 {
u.TokenVersion = 1
}
if u.Role == "" {
u.Role = "member"
}
u.AlarmChannels = parseAlarmChannels(channelsRaw)
if u.AlarmEmail == "" {
u.AlarmEmail = u.Email
}
return u, nil
}
func (s *Store) CountUsers() (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
cleanEmail := strings.ToLower(strings.TrimSpace(email))
username := strings.Split(cleanEmail, "@")[0]
return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role)
}
func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) {
if role != "admin" && role != "member" {
role = "member"
}
cleanEmail := strings.ToLower(strings.TrimSpace(email))
cleanUsername := strings.ToLower(strings.TrimSpace(username))
if cleanUsername == "" {
cleanUsername = strings.Split(cleanEmail, "@")[0]
}
u := &domain.User{
ID: domain.NewID("usr"),
Email: cleanEmail,
Username: cleanUsername,
PasswordHash: passwordHash,
DisplayName: strings.TrimSpace(displayName),
Role: role,
IsActive: true,
TokenVersion: 1,
AlarmChannels: []string{},
AlarmEmail: cleanEmail,
DiscordWebhookURL: "",
CustomWebhookURL: "",
NotifyBeforeMinutes: 30,
AvatarURL: "",
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
return s.GetUserByLogin(email)
}
func (s *Store) GetUserByLogin(login string) (*domain.User, error) {
cleanLogin := strings.ToLower(strings.TrimSpace(login))
if cleanLogin == "" {
return nil, errors.New("benutzername oder e-mail darf nicht leer sein")
}
row := s.db.QueryRow(
`SELECT `+userSelectCols+` FROM users
WHERE LOWER(email) = ?
OR (username != '' AND LOWER(username) = ?)
OR LOWER(display_name) = ?
OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?)
LIMIT 1`,
cleanLogin, cleanLogin, cleanLogin, cleanLogin,
)
return scanUser(row)
}
// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation
func (s *Store) EnsureDemoAccounts() error {
s.initMu.Lock()
defer s.initMu.Unlock()
adminHash, err := auth.HashPassword("admin")
if err != nil {
return err
}
userHash, err := auth.HashPassword("user")
if err != nil {
return err
}
// 1. Admin user (admin / admin)
adminCreated := false
adminUser, err := s.GetUserByLogin("admin")
if err != nil || adminUser == nil {
adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin")
if err != nil {
return fmt.Errorf("failed to create demo admin: %w", err)
}
adminCreated = true
} else {
_ = s.UpdateUserPassword(adminUser.ID, adminHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID)
}
// 2. Regular user (user / user)
normalUser, err := s.GetUserByLogin("user")
if err != nil || normalUser == nil {
normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member")
if err != nil {
return fmt.Errorf("failed to create demo user: %w", err)
}
} else {
_ = s.UpdateUserPassword(normalUser.ID, userHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
}
// 3. Sample data for the admin, partly shared with the demo user (only on first start). Both accounts
// share an organization so they can see each other in the default sharing mode.
if adminCreated {
if org, err := s.CreateOrganization("Demo"); err == nil {
_ = s.SetUserOrganization(adminUser.ID, org.ID)
_ = s.SetUserOrganization(normalUser.ID, org.ID)
}
_ = s.SeedStarterContent(adminUser)
s.seedDemoShares(adminUser, normalUser)
}
return nil
}
func (s *Store) GetUserByID(id string) (*domain.User, error) {
row := s.db.QueryRow(
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
id,
)
return scanUser(row)
}
func (s *Store) GetFirstUser() (*domain.User, error) {
row := s.db.QueryRow(
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC LIMIT 1`,
)
return scanUser(row)
}
func (s *Store) GetAllUsers() ([]domain.User, error) {
rows, err := s.db.Query(
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`,
)
if err != nil {
return nil, err
}
users := make([]domain.User, 0)
for rows.Next() {
if u, err := scanUser(rows); err == nil {
users = append(users, *u)
}
}
_ = rows.Close()
return users, nil
}
func (s *Store) UpdateUser(
userID string,
displayName, email, role *string,
isActive *bool,
discordWebhookURL *string,
notifyBeforeMinutes *int,
) (*domain.User, error) {
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes, nil)
}
func (s *Store) UpdateUserFull(
userID string,
displayName, email, role *string,
isActive *bool,
alarmChannels *[]string,
alarmEmail, discordWebhookURL, customWebhookURL *string,
notifyBeforeMinutes *int,
avatarURL *string,
) (*domain.User, error) {
u, err := s.GetUserByID(userID)
if err != nil {
return nil, err
}
if displayName != nil {
u.DisplayName = strings.TrimSpace(*displayName)
}
if email != nil {
u.Email = strings.ToLower(strings.TrimSpace(*email))
}
if role != nil && (*role == "admin" || *role == "member") {
u.Role = *role
}
if isActive != nil {
u.IsActive = *isActive
}
if alarmChannels != nil {
u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ","))
}
if alarmEmail != nil {
u.AlarmEmail = strings.TrimSpace(*alarmEmail)
}
if discordWebhookURL != nil {
u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL)
}
if customWebhookURL != nil {
u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL)
}
if notifyBeforeMinutes != nil {
u.NotifyBeforeMinutes = *notifyBeforeMinutes
}
if avatarURL != nil {
u.AvatarURL = strings.TrimSpace(*avatarURL)
}
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
u.TokenVersion++
}
activeInt := 0
if u.IsActive {
activeInt = 1
}
channelsStr := strings.Join(u.AlarmChannels, ",")
_, err = s.db.Exec(
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
_, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID)
return err
}
func (s *Store) DeleteUser(userID string) error {
// Remove owned data properly first (other people's tasks in the user's lists stay theirs)
if err := s.PurgeUserData(userID); err != nil {
return err
}
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
// Shares pointing at the deleted account are no longer visible to anyone
s.pruneSharingNow()
return err
}
// RevokeToken adds an individual JWT to the blacklist until its expiration date
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
hash := auth.HashAPIKey(tokenStr)
_, err := s.db.Exec(
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
)
// Idempotently prune old expired tokens
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
return err
}
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
func (s *Store) IsTokenRevoked(tokenStr string) bool {
hash := auth.HashAPIKey(tokenStr)
var count int
err := s.db.QueryRow(
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
hash, time.Now().UTC().Format(time.RFC3339),
).Scan(&count)
return err == nil && count > 0
}
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
func (s *Store) RevokeAllUserTokens(userID string) error {
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
return err
}
// --- USER INVITES ---
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
if role != "admin" && role != "member" {
role = "member"
}
if validDays <= 0 || validDays > 365 {
validDays = 7
}
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
now := time.Now().UTC()
inv := &domain.UserInvite{
ID: domain.NewID("inv"),
Code: code,
Email: strings.ToLower(strings.TrimSpace(email)),
Role: role,
OrganizationID: strings.TrimSpace(orgID),
CreatedBy: createdBy,
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
}
_, err := s.db.Exec(
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
)
if err != nil {
return nil, err
}
return inv, nil
}
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
rows, err := s.db.Query(
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
FROM user_invites i
LEFT JOIN organizations o ON o.id = i.organization_id
ORDER BY i.created_at DESC`,
)
if err != nil {
return []domain.UserInvite{}, err
}
defer rows.Close()
invites := make([]domain.UserInvite, 0)
for rows.Next() {
var inv domain.UserInvite
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
invites = append(invites, inv)
}
}
return invites, nil
}
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
code = strings.ToUpper(strings.TrimSpace(code))
var inv domain.UserInvite
err := s.db.QueryRow(
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at
FROM user_invites WHERE UPPER(code) = ?`,
code,
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
if err != nil {
return nil, errors.New("Ungültiger Einladungscode")
}
if inv.UsedBy != "" {
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
}
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
return nil, errors.New("Dieser Einladungscode ist abgelaufen")
}
return &inv, nil
}
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
return err
}
func (s *Store) DeleteUserInvite(inviteID string) error {
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
return err
}