package storage import ( "context" "database/sql" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" _ "modernc.org/sqlite" ) type Store struct { db *sql.DB docDir string initMu sync.Mutex } func NewStore(dbPath string) (*Store, error) { var docDir string if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") { dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err) } docDir = filepath.Join(dir, "workspace") _ = os.MkdirAll(docDir, 0755) } dsn := dbPath if !strings.Contains(dsn, "?") { dsn += "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)" } db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("failed to open sqlite db: %w", err) } db.SetMaxOpenConns(8) db.SetMaxIdleConns(4) store := &Store{db: db, docDir: docDir} if err := store.migrate(); err != nil { return nil, fmt.Errorf("failed to run migrations: %w", err) } return store, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate() error { schema := ` PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, username TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL, display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', is_active INTEGER NOT NULL DEFAULT 1, alarm_channels TEXT NOT NULL DEFAULT 'discord', alarm_email TEXT NOT NULL DEFAULT '', discord_webhook_url TEXT NOT NULL DEFAULT '', custom_webhook_url TEXT NOT NULL DEFAULT '', notify_before_minutes INTEGER NOT NULL DEFAULT 30, avatar_url TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS user_invites ( id TEXT PRIMARY KEY, code TEXT UNIQUE NOT NULL, email TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT 'member', team_id TEXT NOT NULL DEFAULT '', created_by TEXT NOT NULL, expires_at TEXT NOT NULL, used_by TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS thoughts ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, content TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '[]', priority TEXT NOT NULL DEFAULT 'normal', source TEXT NOT NULL DEFAULT 'manual', status TEXT NOT NULL DEFAULT 'inbox', is_completed INTEGER NOT NULL DEFAULT 0, converted_type TEXT DEFAULT '', converted_ref_id TEXT DEFAULT '', due_date TEXT DEFAULT '', task_list_id TEXT NOT NULL DEFAULT '', kanban_status TEXT NOT NULL DEFAULT 'todo', notes TEXT NOT NULL DEFAULT '', alarm_sent INTEGER NOT NULL DEFAULT 0, subtasks TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, triaged_at TEXT DEFAULT '', FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date); CREATE INDEX IF NOT EXISTS idx_thoughts_task_list ON thoughts(task_list_id); CREATE TABLE IF NOT EXISTS task_lists ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, space_id TEXT DEFAULT '', team_id TEXT DEFAULT '', title TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '📋', color TEXT NOT NULL DEFAULT '#2563eb', columns TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC); CREATE TABLE IF NOT EXISTS organizations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_id TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS teams ( id TEXT PRIMARY KEY, org_id TEXT NOT NULL DEFAULT 'default', name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS team_members ( team_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', joined_at TEXT NOT NULL, PRIMARY KEY (team_id, user_id), FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, space_id TEXT DEFAULT '', title TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '📄', content TEXT NOT NULL DEFAULT '', blocks_json TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]', folder_path TEXT NOT NULL DEFAULT '', is_pinned INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC); CREATE TABLE IF NOT EXISTS doc_shares ( id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, user_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, user_id), FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id); CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id); CREATE TABLE IF NOT EXISTS doc_pins ( user_id TEXT NOT NULL, doc_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(user_id, doc_id), FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_doc_pins_user ON doc_pins(user_id); INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1; CREATE TABLE IF NOT EXISTS doc_block_tags ( id TEXT PRIMARY KEY, tag TEXT NOT NULL, user_id TEXT NOT NULL, doc_id TEXT NOT NULL, block_id TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(doc_id, block_id, tag), FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_doc_block_tags_tag ON doc_block_tags(tag); CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag); CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id); CREATE TABLE IF NOT EXISTS tag_pages ( user_id TEXT NOT NULL, tag TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, PRIMARY KEY(user_id, tag) ); CREATE TABLE IF NOT EXISTS lists ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🛒', list_type TEXT NOT NULL DEFAULT 'shopping', updated_at TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC); CREATE TABLE IF NOT EXISTS list_shares ( id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(list_id, target_type, target_id), FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE, FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id); CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id); CREATE TABLE IF NOT EXISTS list_items ( id TEXT PRIMARY KEY, list_id TEXT NOT NULL, content TEXT NOT NULL, category TEXT NOT NULL DEFAULT '', quantity TEXT NOT NULL DEFAULT '', is_completed INTEGER NOT NULL DEFAULT 0, completed_by TEXT NOT NULL DEFAULT '', completed_by_name TEXT NOT NULL DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, completed_at TEXT NOT NULL DEFAULT '', FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC); CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, key_prefix TEXT NOT NULL, key_hash TEXT UNIQUE NOT NULL, scopes TEXT NOT NULL DEFAULT 'ingest,read', last_used_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS system_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); ` if _, err := s.db.Exec(schema); err != nil { return err } // Idempotent column migrations for existing databases columnMigrations := []string{ "ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';", "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';", "ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;", "ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';", "ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN custom_webhook_url TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN notify_before_minutes INTEGER NOT NULL DEFAULT 30;", "ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';", "ALTER TABLE thoughts ADD COLUMN task_list_id TEXT NOT NULL DEFAULT '';", "ALTER TABLE thoughts ADD COLUMN kanban_status TEXT NOT NULL DEFAULT 'todo';", "ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';", "ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;", "ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';", "ALTER TABLE thoughts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;", "CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);", "ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';", "CREATE TABLE IF NOT EXISTS doc_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, user_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, user_id));", "CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);", "CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);", "ALTER TABLE task_lists ADD COLUMN team_id TEXT NOT NULL DEFAULT '';", "ALTER TABLE task_lists ADD COLUMN columns TEXT NOT NULL DEFAULT '[]';", "CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));", "CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);", "CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);", "CREATE TABLE IF NOT EXISTS doc_team_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, team_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, team_id));", "CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);", "CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_id);", "ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';", "CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));", "CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);", "CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);", // Workspace Shared Folders & User Folder Placements "ALTER TABLE documents ADD COLUMN shared_folder_id TEXT NOT NULL DEFAULT '';", "ALTER TABLE documents ADD COLUMN sub_folder_path TEXT NOT NULL DEFAULT '';", "CREATE INDEX IF NOT EXISTS idx_documents_shared_folder ON documents(shared_folder_id);", "CREATE TABLE IF NOT EXISTS workspace_shared_folders (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);", "CREATE INDEX IF NOT EXISTS idx_workspace_shared_folders_owner ON workspace_shared_folders(owner_id);", "CREATE TABLE IF NOT EXISTS workspace_folder_shares (id TEXT PRIMARY KEY, folder_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(folder_id, target_type, target_id));", "CREATE INDEX IF NOT EXISTS idx_workspace_folder_shares_folder ON workspace_folder_shares(folder_id);", "CREATE INDEX IF NOT EXISTS idx_workspace_folder_shares_target ON workspace_folder_shares(target_type, target_id);", "CREATE TABLE IF NOT EXISTS workspace_folder_mounts (user_id TEXT NOT NULL, folder_id TEXT NOT NULL, mount_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, folder_id));", "CREATE INDEX IF NOT EXISTS idx_workspace_folder_mounts_user ON workspace_folder_mounts(user_id);", "CREATE TABLE IF NOT EXISTS doc_user_folders (user_id TEXT NOT NULL, doc_id TEXT NOT NULL, folder_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, doc_id));", "CREATE INDEX IF NOT EXISTS idx_doc_user_folders_user ON doc_user_folders(user_id);", "CREATE TABLE IF NOT EXISTS workspace_folders (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, shared_folder_id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, sub_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL);", "CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);", "CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);", "CREATE TABLE IF NOT EXISTS document_blocks (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, sort_order REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', content TEXT NOT NULL DEFAULT '', level INTEGER NOT NULL DEFAULT 0, checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, updated_by TEXT NOT NULL DEFAULT '', FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE);", "CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) } // Clean up legacy space_id from documents and task_lists if empty or orphaned _, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`) _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`) _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`) // Backfill username for existing accounts where username is empty _, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`) // Default system settings (public registration disabled by default; invite or admin creation required) _, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES ('sharing_mode', 'teams_only'), ('allow_registration', 'false'), ('instance_name', 'MindSift'), ('smtp_host', ''), ('smtp_port', '587'), ('smtp_user', ''), ('smtp_pass', ''), ('smtp_from', ''), ('smtp_tls', 'true')`) // Migrate existing teams table if it still has a legacy FOREIGN KEY to organizations var teamsTableSQL string _ = s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='teams'`).Scan(&teamsTableSQL) if strings.Contains(strings.ToUpper(teamsTableSQL), "ORGANIZATIONS") { if conn, err := s.db.Conn(context.Background()); err == nil { _, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = OFF;`) _, _ = conn.ExecContext(context.Background(), ` BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS teams_migrated ( id TEXT PRIMARY KEY, org_id TEXT NOT NULL DEFAULT 'default', name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); INSERT OR IGNORE INTO teams_migrated (id, org_id, name, description, created_at) SELECT id, COALESCE(NULLIF(org_id, ''), 'default'), name, COALESCE(description, ''), created_at FROM teams; DROP TABLE teams; ALTER TABLE teams_migrated RENAME TO teams; COMMIT; `) _, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = ON;`) _ = conn.Close() } } // Clean up legacy roles in team_members and users _, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`) _, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`) _, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`) // Migrate workspace documents, blocks, folders, and strip legacy folder:* tags into pure DB tables s.migrateWorkspaceToPureDB() // Reindex block tags if table is newly added or empty var tagCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount) if tagCount == 0 { _ = s.reindexAllDocBlockTags() } return nil } // SeedStarterContent populates realistic initial content for a newly created user if requested func (s *Store) SeedStarterContent(user *domain.User) error { now := domain.NowISO() starterThoughts := []struct { content string priority string source string dueDate string }{ { content: "Zündkerzen und Luftfilter für den Rasenmäher bestellen #garage #wartung", priority: "normal", source: "manual", }, { content: "Urlaubsidee: Wanderhütte in Südtirol für Oktober buchen #urlaub #familie", priority: "high", source: "voice", }, { content: "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", priority: "urgent", source: "webhook", }, } for _, st := range starterThoughts { _, _ = s.CreateThought(user.ID, st.content, st.priority, st.source, st.dueDate) } keptTask, err := s.CreateThought(user.ID, "Gitea CI/CD Pipeline für MindSift Container testen #devops #docker", "high", "manual", now[:10]) if err == nil && keptTask != nil { _, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "") } shopList, err := s.CreateList(user.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping") if err == nil && shopList != nil { _, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0) _, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1) } starterBlocks := []domain.DocumentBlock{ {ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."}, {ID: "blk_2", Type: "h2", Content: "Projekt-Checkliste"}, {ID: "blk_3", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true}, } _, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Notizen", "🚀", "", starterBlocks, []string{"mindsift", "workflow"}, true) return nil } // EnsureDefaultUser is retained strictly for unit test convenience func (s *Store) EnsureDefaultUser() (*domain.User, error) { s.initMu.Lock() defer s.initMu.Unlock() count, err := s.CountUsers() if err != nil { return nil, err } if count > 0 { return s.GetFirstUser() } hash, err := auth.HashPassword("mindsift") if err != nil { return nil, err } user, err := s.CreateUser("test@mindsift.local", hash, "Admin", "admin") if err != nil { return s.GetFirstUser() } return user, nil } // --- USERS --- const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, 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, &channelsRaw, &u.AlarmEmail, &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt, ) if err != nil { return nil, err } u.IsActive = isActiveInt != 0 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, 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, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at) VALUES (?, ?, ?, ?, ?, ?, 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) 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) } _ = s.SeedStarterContent(adminUser) } 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 { _, 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) } 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) GetDiscoverableUsers(viewerID string) ([]domain.User, error) { viewer, err := s.GetUserByID(viewerID) if err != nil { return []domain.User{}, nil } sys, _ := s.GetSystemSettings() mode := "teams_only" if sys != nil && sys.SharingMode != "" { mode = sys.SharingMode } if viewer.Role == "admin" || mode == "open" || mode == "teams_only" { return s.GetAllUsers() } if mode == "strict_private" { return []domain.User{*viewer}, nil } // users sharing at least one team, organization or space with viewer rows, err := s.db.Query( `SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN ( SELECT tm2.user_id FROM team_members tm1 JOIN team_members tm2 ON tm1.team_id = tm2.team_id WHERE tm1.user_id = ? )`, viewerID, viewerID, ) if err != nil { return []domain.User{*viewer}, nil } 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) } 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 = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`, u.DisplayName, u.Email, u.Role, activeInt, 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 = ? WHERE id = ?`, passwordHash, userID) return err } func (s *Store) DeleteUser(userID string) error { _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID) return err } // --- 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) if content == "" { return nil, errors.New("content cannot be empty") } if priority == "" { priority = "normal" } if source == "" { source = "manual" } if kanbanStatus == "" { kanbanStatus = "todo" } tags := domain.ExtractTags(content) 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, ) 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) 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 (team_id != '' AND team_id IN (SELECT team_id FROM team_members 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 FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, thoughtID, userID, userID, userID, userID, userID, ) return scanThought(row) } 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 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 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 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, 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 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, 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() 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() for _, it := range items { if it.KanbanStatus != nil && *it.KanbanStatus != "" { isCompleted := 0 if *it.KanbanStatus == "done" { isCompleted = 1 } _, err = tx.Exec( `UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ? WHERE id = ?`, it.SortOrder, *it.KanbanStatus, isCompleted, it.ID, ) } else { _, err = tx.Exec( `UPDATE thoughts SET sort_order = ? WHERE id = ?`, it.SortOrder, it.ID, ) } if err != nil { return err } } return tx.Commit() } // 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 if dueDate == "" { dueDate = thought.DueDate } if taskListID == "" { taskListID = thought.TaskListID } 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) formattedDate := domain.FormatGermanDateTime(thought.CreatedAt) calloutText := "Ursprünglicher Gedanke aus deiner Inbox" if formattedDate != "" { calloutText += " vom " + formattedDate } blocks = append(blocks, domain.DocumentBlock{ ID: domain.NewID("blk"), Type: "callout", Content: calloutText, Level: 0, }) if strings.TrimSpace(thought.Notes) != "" { blocks = append(blocks, domain.DocumentBlock{ ID: domain.NewID("blk"), Type: "bullet", Content: strings.TrimSpace(thought.Notes), Level: 0, }) } 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: 0, }) } } 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) 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 = ? WHERE id = ? AND user_id = ?`, string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, thoughtID, userID, ) if err != nil { return nil, err } 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 } 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 = *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 } return thought, nil } func (s *Store) DeleteThought(userID, thoughtID string) error { thought, err := s.GetThoughtByID(userID, thoughtID) if 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() } // --- DOCUMENTS (WORKSPACE - PURE DATABASE ARCHITECTURE) --- func stripFolderTags(tags []string) []string { if len(tags) == 0 { return []string{} } cleaned := make([]string, 0, len(tags)) for _, t := range tags { if !strings.HasPrefix(strings.TrimSpace(t), "folder:") { cleaned = append(cleaned, t) } } return cleaned } func (s *Store) migrateWorkspaceToPureDB() { now := domain.NowISO() // 1. Migrate documents.blocks_json into document_blocks for any doc without rows in document_blocks rows, err := s.db.Query(` SELECT d.id, d.user_id, d.blocks_json, d.tags, d.folder_path, d.updated_at FROM documents d `) if err == nil { type migDoc struct { id, userID, blocksJSON, tagsJSON, folderPath, updatedAt string } var docsToMigrate []migDoc for rows.Next() { var md migDoc if err := rows.Scan(&md.id, &md.userID, &md.blocksJSON, &md.tagsJSON, &md.folderPath, &md.updatedAt); err == nil { docsToMigrate = append(docsToMigrate, md) } } _ = rows.Close() for _, md := range docsToMigrate { var count int _ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, md.id).Scan(&count) if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" { var blocks []domain.DocumentBlock if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 { ts := md.updatedAt if ts == "" { ts = now } s.replaceDocBlocksInDB(md.id, md.userID, blocks, ts) } } // Strip legacy folder:* tags from documents.tags if strings.Contains(md.tagsJSON, "folder:") { var tags []string if err := json.Unmarshal([]byte(md.tagsJSON), &tags); err == nil { cleaned := stripFolderTags(tags) cleanedBytes, _ := json.Marshal(cleaned) _, _ = s.db.Exec(`UPDATE documents SET tags = ? WHERE id = ?`, string(cleanedBytes), md.id) } } // Ensure personal folder exists in workspace_folders if md.folderPath != "" { var existsID string err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ?`, md.userID, md.folderPath).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 (?, ?, '', ?, '', ?)`, domain.NewID("wfld"), md.userID, md.folderPath, now) } } } } // 2. Sync all existing shared folders so all docs/subfolders in their owner's mount path are linked sfRows, err := s.db.Query(` SELECT f.id, f.owner_id, 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 `) if err == nil { type sfInfo struct{ id, ownerID, mountPath string } var sfs []sfInfo for sfRows.Next() { var info sfInfo if err := sfRows.Scan(&info.id, &info.ownerID, &info.mountPath); err == nil { sfs = append(sfs, info) } } _ = sfRows.Close() for _, info := range sfs { s.syncSharedFolderContents(info.ownerID, info.id, info.mountPath) } } } func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock { rows, err := s.db.Query(` SELECT id, type, content, level, checked, collapsed, lang FROM document_blocks WHERE doc_id = ? ORDER BY sort_order ASC, rowid ASC `, docID) if err == nil { blocks := make([]domain.DocumentBlock, 0) for rows.Next() { var b domain.DocumentBlock var chk, col int if scanErr := rows.Scan(&b.ID, &b.Type, &b.Content, &b.Level, &chk, &col, &b.Lang); scanErr == nil { b.Checked = chk != 0 b.Collapsed = col != 0 blocks = append(blocks, b) } } _ = rows.Close() if len(blocks) > 0 { return blocks } } blocks := make([]domain.DocumentBlock, 0) if fallbackJSON != "" && fallbackJSON != "[]" { _ = json.Unmarshal([]byte(fallbackJSON), &blocks) if len(blocks) > 0 { s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO()) } } return blocks } func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, now string) { _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID) if len(blocks) == 0 { return } stmt, err := s.db.Prepare(` INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET doc_id = excluded.doc_id, sort_order = excluded.sort_order, type = excluded.type, content = excluded.content, level = excluded.level, checked = excluded.checked, collapsed = excluded.collapsed, lang = excluded.lang, updated_at = excluded.updated_at, updated_by = excluded.updated_by `) if err != nil { return } defer stmt.Close() for i, b := range blocks { bID := strings.TrimSpace(b.ID) if bID == "" { bID = domain.NewID("blk") } bType := b.Type if bType == "" { bType = "bullet" } chk := 0 if b.Checked { chk = 1 } col := 0 if b.Collapsed { col = 1 } _, _ = stmt.Exec(bID, docID, float64((i+1)*1000), bType, b.Content, b.Level, chk, col, b.Lang, now, userID) } } // PatchDocumentBlocks applies atomic block-level upserts, deletions, and order reconciliation without overwriting concurrent edits from other sessions. func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPatchRequest) (*domain.Document, []string, error) { doc, err := s.GetDocumentByID(userID, docID) if err != nil { return nil, nil, err } if !doc.IsOwner && doc.Permission != "write" { return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") } now := domain.NowISO() // Ensure document_blocks is seeded if previously only in blocks_json var existingCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM document_blocks WHERE doc_id = ?`, docID).Scan(&existingCount) if existingCount == 0 && len(doc.Blocks) > 0 { s.replaceDocBlocksInDB(docID, doc.UserID, doc.Blocks, doc.UpdatedAt) } // Read current ordered block IDs from DB before patch var existingOrder []string ordRows, err := s.db.Query(`SELECT id FROM document_blocks WHERE doc_id = ? ORDER BY sort_order ASC, rowid ASC`, docID) if err == nil { for ordRows.Next() { var bid string if err := ordRows.Scan(&bid); err == nil { existingOrder = append(existingOrder, bid) } } _ = ordRows.Close() } // 1. Delete explicitly deleted block IDs deletedSet := make(map[string]bool, len(req.DeletedIDs)) for _, delID := range req.DeletedIDs { delID = strings.TrimSpace(delID) if delID == "" { continue } deletedSet[delID] = true _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ? AND id = ?`, docID, delID) } // Filter existingOrder to exclude deleted blocks survivingExisting := make([]string, 0, len(existingOrder)) survivingSet := make(map[string]bool, len(existingOrder)) for _, bid := range existingOrder { if !deletedSet[bid] { survivingExisting = append(survivingExisting, bid) survivingSet[bid] = true } } // 2. Upsert modified or newly created blocks upsertedIDs := make([]string, 0, len(req.Upserted)) for idx, b := range req.Upserted { bID := strings.TrimSpace(b.ID) if bID == "" || deletedSet[bID] { continue } bType := b.Type if bType == "" { bType = "bullet" } chk := 0 if b.Checked { chk = 1 } col := 0 if b.Collapsed { col = 1 } defaultSort := float64((len(survivingExisting) + idx + 1) * 1000) _, _ = s.db.Exec(` INSERT INTO document_blocks (id, doc_id, sort_order, type, content, level, checked, collapsed, lang, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET type = excluded.type, content = excluded.content, level = excluded.level, checked = excluded.checked, collapsed = excluded.collapsed, lang = excluded.lang, updated_at = excluded.updated_at, updated_by = excluded.updated_by `, bID, docID, defaultSort, bType, b.Content, b.Level, chk, col, b.Lang, now, userID) upsertedIDs = append(upsertedIDs, bID) survivingSet[bID] = true } // 3. Reconcile block ordering without dropping blocks concurrently created by other users var mergedOrder []string inMerged := make(map[string]bool) if len(req.BlockOrder) > 0 { for _, bid := range req.BlockOrder { if !deletedSet[bid] && survivingSet[bid] && !inMerged[bid] { mergedOrder = append(mergedOrder, bid) inMerged[bid] = true } } // Any surviving block in DB that was not in req.BlockOrder (e.g. concurrently added by another user) // is inserted right after its predecessor from survivingExisting! for i, bid := range survivingExisting { if inMerged[bid] { continue } insertPos := 0 for j := i - 1; j >= 0; j-- { predID := survivingExisting[j] if inMerged[predID] { for k, mID := range mergedOrder { if mID == predID { insertPos = k + 1 break } } break } } mergedOrder = append(mergedOrder[:insertPos], append([]string{bid}, mergedOrder[insertPos:]...)...) inMerged[bid] = true } // Ensure any upserted block not in BlockOrder is also appended for _, bid := range upsertedIDs { if !inMerged[bid] { mergedOrder = append(mergedOrder, bid) inMerged[bid] = true } } } else { for _, bid := range survivingExisting { if !inMerged[bid] { mergedOrder = append(mergedOrder, bid) inMerged[bid] = true } } for _, bid := range upsertedIDs { if !inMerged[bid] { mergedOrder = append(mergedOrder, bid) inMerged[bid] = true } } } // Update sort_order in document_blocks for idx, bid := range mergedOrder { _, _ = s.db.Exec(`UPDATE document_blocks SET sort_order = ? WHERE doc_id = ? AND id = ?`, float64((idx+1)*1000), docID, bid) } // Reload authoritative block list and update document snapshot finalBlocks := s.loadDocBlocks(docID, "") var plainBuilder strings.Builder for _, b := range finalBlocks { if b.Content != "" { plainBuilder.WriteString(b.Content) plainBuilder.WriteString("\n") } } plainContent := plainBuilder.String() blocksJSON, _ := json.Marshal(finalBlocks) _, _ = s.db.Exec(`UPDATE documents SET content = ?, blocks_json = ?, updated_at = ? WHERE id = ?`, plainContent, string(blocksJSON), now, docID) doc.Blocks = finalBlocks doc.Content = plainContent doc.UpdatedAt = now _ = s.indexDocBlockTags(doc.ID, doc.UserID, finalBlocks) return doc, mergedOrder, nil } // 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) 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 = ?`, userID, docID, userID, ) doc, err := s.scanDocumentWithBlocks(row) if err == nil { doc.Permission = "owner" doc.IsOwner = true resolveDocumentPath(userID, doc, mounts, names, userFolders) if shares, err := s.GetDocShares(doc.ID); err == nil { doc.SharedWith = shares } return doc, nil } // Try shared document (direct, 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 (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, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) { doc, err := s.GetDocumentByID(userID, docID) if err != nil { return nil, err } isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange && !isOnlyFolderMove { 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 isOnlyPinChange { return doc, nil } if folderPath != nil { movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath) if moveErr != nil { return nil, moveErr } doc.FolderPath = movedDoc.FolderPath doc.SharedFolderID = movedDoc.SharedFolderID doc.SubFolderPath = movedDoc.SubFolderPath doc.IsSharedFolder = movedDoc.IsSharedFolder } if isOnlyFolderMove { return doc, nil } if title != nil { doc.Title = strings.TrimSpace(*title) } if icon != nil && *icon != "" { doc.Icon = *icon } if tags != nil { doc.Tags = stripFolderTags(*tags) } doc.UpdatedAt = domain.NowISO() if blocks != nil { doc.Blocks = *blocks s.replaceDocBlocksInDB(docID, userID, doc.Blocks, doc.UpdatedAt) var plainBuilder strings.Builder for _, b := range doc.Blocks { if b.Content != "" { plainBuilder.WriteString(b.Content) plainBuilder.WriteString("\n") } } doc.Content = plainBuilder.String() } blocksJSON, _ := json.Marshal(doc.Blocks) tagsJSON, _ := json.Marshal(doc.Tags) // IMPORTANT: Never overwrite documents.folder_path here! Folder placement is managed exclusively by MoveDocument. _, err = s.db.Exec( `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, updated_at = ? WHERE id = ?`, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.UpdatedAt, docID, ) if err != nil { return nil, err } if blocks != nil { _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) } return doc, nil } 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) } _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID) _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID) _, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID) return err } 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, userID string, blocks []domain.DocumentBlock) error { _, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID) if len(blocks) == 0 { return nil } stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO doc_block_tags (id, tag, user_id, doc_id, block_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`) if err != nil { return err } defer stmt.Close() now := domain.NowISO() for _, b := range blocks { if b.Content == "" { continue } tags := domain.ExtractTags(b.Content) for _, tag := range tags { tagID := domain.NewID("dbtag") _, _ = stmt.Exec(tagID, tag, userID, docID, b.ID, now) } } return nil } 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.doc_id || ':' || t.block_id) as count FROM doc_block_tags t JOIN documents d ON t.doc_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 WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL) AND d.is_archived = 0 GROUP BY t.tag ORDER BY count DESC, t.tag ASC`, 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(¬eContent, ¬eUpdatedAt) 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, '') as shared_permission FROM doc_block_tags t JOIN documents d ON t.doc_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 WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL) AND t.tag = ? AND d.is_archived = 0 ORDER BY d.updated_at DESC`, 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 } 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() // Delete documents from SQLite, blocks, pins, shares, tags, user_folders for _, id := range deletedDocIDs { _, _ = s.db.Exec(`DELETE FROM document_blocks WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, id) _, _ = s.db.Exec(`DELETE FROM documents WHERE id = ?`, 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 } cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath))) if cleanTarget == "." { cleanTarget = "" } mounts, _, err := s.getUserFolderMounts(userID) if err != nil { return nil, err } now := domain.NowISO() // Check if target folder falls within any mounted shared folder targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts) if isTargetShared { // Moving into / within a shared folder shFolder, err := s.GetSharedFolderByID(userID, targetFolderID) if err != nil { return nil, err } if !shFolder.IsOwner && shFolder.Permission != "write" { return nil, fmt.Errorf("keine Schreibberechtigung für diesen geteilten Ordner") } // Update document to belong to targetSharedFolderID _, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`, targetFolderID, subPath, now, docID) if err != nil { return nil, err } // Remove personal override if any _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) doc.SharedFolderID = targetFolderID doc.SubFolderPath = subPath doc.IsSharedFolder = true doc.FolderPath = cleanTarget return doc, nil } // Target is NOT inside a shared folder (personal / standalone) if doc.SharedFolderID != "" { // Moving OUT of a shared folder into a personal folder if !doc.IsOwner && doc.Permission != "write" { return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben") } _, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID) if err != nil { return nil, err } doc.SharedFolderID = "" doc.SubFolderPath = "" doc.IsSharedFolder = false doc.FolderPath = cleanTarget return doc, nil } // Standalone note if doc.IsOwner { // Owner moves their own document -> updates documents.folder_path _, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID) if err != nil { return nil, err } doc.FolderPath = cleanTarget return doc, nil } // Recipient moves an individually shared note -> updates doc_user_folders ONLY! _, err = s.db.Exec(` INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at `, userID, docID, cleanTarget, now, now) if err != nil { return nil, err } doc.FolderPath = cleanTarget return doc, nil } // 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 && f.Permission != "write" { return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Ordner-Freigaben verwalten") } cleanType := strings.TrimSpace(targetType) cleanTarget := strings.TrimSpace(targetIdentifier) if cleanType == "" { if strings.HasPrefix(cleanTarget, "team_") { cleanType = "team" } else { cleanType = "user" } } if permission != "read" && permission != "write" { permission = "read" } var targetID, targetName, email, avatarURL string if cleanType == "team" { err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) if err != nil { return nil, fmt.Errorf("gruppe nicht gefunden") } } else { var u *domain.User if strings.HasPrefix(cleanTarget, "usr_") { u, _ = s.GetUserByID(cleanTarget) } if u == nil { u, _ = s.GetUserByLogin(cleanTarget) } if u == nil { u, err = s.GetUserByID(cleanTarget) } if err != nil || u == nil { if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil { cleanType = "team" } else { return nil, fmt.Errorf("benutzer nicht gefunden") } } else { if u.ID == f.OwnerID { return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer") } targetID = u.ID targetName = u.DisplayName email = u.Email avatarURL = u.AvatarURL } } now := domain.NowISO() shareID := domain.NewID("fsh") _, err = s.db.Exec(` INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission `, shareID, folderID, cleanType, targetID, permission, currentUserID, now) if err != nil { return nil, err } 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) if !f.IsOwner && f.Permission != "write" { _, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`, folderID, currentUserID, cleanID, cleanID) _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID) return err } var targetType, targetID string _ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID).Scan(&targetType, &targetID) _, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID) if err != nil { return err } if targetType == "user" && targetID != "" { _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID) } return nil } func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error { f, err := s.GetSharedFolderByID(currentUserID, folderID) if err != nil { return err } if !f.IsOwner { return s.DeleteFolderShare(currentUserID, folderID, currentUserID) } _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID) _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID) _, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID) _, _ = 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 } 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 && doc.Permission != "write" { return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter 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 { return nil, fmt.Errorf("team nicht gefunden") } if permission != "read" && permission != "write" { permission = "read" } 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 } 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.IsOwner && doc.Permission != "write" { return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter 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) } if permission != "read" && permission != "write" { permission = "read" } // 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, fmt.Errorf("benutzer nicht gefunden") } if targetUser.ID == doc.UserID { return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer") } 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 } if !doc.IsOwner && doc.Permission != "write" && 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 } func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { rows, err := s.db.Query( `SELECT id, email, display_name, role, is_active, avatar_url, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`, currentUserID, ) if err != nil { return nil, err } defer rows.Close() users := make([]domain.User, 0) for rows.Next() { var u domain.User var activeInt int if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.AvatarURL, &u.CreatedAt); err == nil { u.IsActive = activeInt == 1 users = append(users, u) } } return users, nil } // --- 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, fmt.Errorf("benutzer nicht gefunden: %s", targetID) } targetID = targetUser.ID } // Verify current user is owner or editor of the list 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 { var myRole string err = s.db.QueryRow(`SELECT role FROM list_shares WHERE list_id = ? AND target_type = 'user' AND target_id = ?`, listID, currentUserID).Scan(&myRole) if err != nil || myRole != "editor" { return nil, errors.New("keine Berechtigung zum Teilen dieser Liste") } } 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) } 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) return &l, nil } 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 id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at FROM list_items WHERE list_id = ? ORDER BY is_completed ASC, sort_order ASC, 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.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) (*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, CreatedAt: now, } _, err := s.db.Exec( `INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`, it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, ) 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) 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() } // --- 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) } // --- 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) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 0`, userID).Scan(&st.TasksOpenCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 1`, 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) { q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%" tRows, 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 FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`, userID, q, ) thoughts := make([]domain.Thought, 0) if err == nil { for tRows.Next() { if t, err := scanThought(tRows); err == nil { thoughts = append(thoughts, *t) } } _ = tRows.Close() } dRows, err := s.db.Query( `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, d.is_archived, d.created_at, d.updated_at, d.shared_folder_id, d.sub_folder_path FROM documents d LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? WHERE d.user_id = ? AND (LOWER(d.title) LIKE ? OR LOWER(d.content) LIKE ?) ORDER BY is_pinned DESC, d.updated_at DESC LIMIT 20`, userID, userID, q, q, ) docs := make([]domain.Document, 0) if err == nil { for dRows.Next() { if d, err := scanDocument(dRows); err == nil { docs = append(docs, *d) } } _ = dRows.Close() } return thoughts, docs, 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 } // --- 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, TeamID: teamID, 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, team_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt, ) if err != nil { return nil, err } if teamID != "" { _, _ = s.AddTaskListShare(tl.ID, "team", teamID, userID) } return s.GetTaskListByID(userID, tl.ID) } func (s *Store) AddTaskListShare(taskListID, targetType, targetID, 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") } now := domain.NowISO() share := &domain.TaskListShare{ ID: domain.NewID("tls"), TaskListID: taskListID, TargetType: targetType, TargetID: targetID, CreatedAt: now, } _, err := s.db.Exec( `INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at`, share.ID, share.TaskListID, share.TargetType, share.TargetID, 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.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID) _ = 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 { var targetType, targetID string _ = s.db.QueryRow(`SELECT target_type, target_id FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, taskListID, shareIDOrTargetID, shareIDOrTargetID).Scan(&targetType, &targetID) _, err := s.db.Exec( `DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, taskListID, shareIDOrTargetID, shareIDOrTargetID, ) if targetType == "team" { _, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID) } 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, 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.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 } 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. Team members if tl.TeamID != "" if tl.TeamID != "" { 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 = ?`, tl.TeamID, ) 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() } } // 3. 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, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at FROM task_lists WHERE user_id = ? OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members 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, 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.TeamID, &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) if list[i].TeamID != "" { _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, list[i].TeamID).Scan(&list[i].TeamName) } shares, _ := s.GetTaskListShares(list[i].ID) list[i].Shares = shares 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, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at FROM task_lists WHERE id = ? AND ( user_id = ? OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members 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 = ?)) ) )`, listID, userID, userID, userID, userID, ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &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) if tl.TeamID != "" { _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, tl.TeamID).Scan(&tl.TeamName) } shares, _ := s.GetTaskListShares(tl.ID) tl.Shares = shares tl.Members = s.getMembersForTaskList(&tl) return &tl, nil } func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) { tl, err := s.GetTaskListByID(userID, listID) if err != nil { return nil, err } 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 } if teamID != nil { oldTeamID := tl.TeamID tl.TeamID = *teamID if oldTeamID != "" && oldTeamID != *teamID { _ = s.RemoveTaskListShare(listID, oldTeamID) } if *teamID != "" { _, _ = s.AddTaskListShare(listID, "team", *teamID, userID) } } tl.UpdatedAt = domain.NowISO() colsJSON, _ := json.Marshal(tl.Columns) _, err = s.db.Exec( `UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, columns = ?, updated_at = ? WHERE id = ?`, tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, 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) return err } // --- ORGANIZATIONS & TEAMS --- func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization, error) { name = strings.TrimSpace(name) if name == "" { return nil, errors.New("organization name required") } org := &domain.Organization{ ID: domain.NewID("org"), Name: name, OwnerID: ownerID, Teams: []domain.Team{}, CreatedAt: domain.NowISO(), } _, err := s.db.Exec( `INSERT INTO organizations (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`, org.ID, org.Name, org.OwnerID, org.CreatedAt, ) if err != nil { return nil, err } return org, nil } func (s *Store) DeleteOrganization(orgID string) error { _, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID) return err } func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) { viewer, _ := s.GetUserByID(userID) var rows *sql.Rows var err error if viewer != nil && viewer.Role == "admin" { rows, err = s.db.Query( `SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`, ) } else { rows, err = s.db.Query( `SELECT DISTINCT o.id, o.name, o.owner_id, o.created_at FROM organizations o LEFT JOIN teams t ON t.org_id = o.id LEFT JOIN team_members tm ON tm.team_id = t.id WHERE o.owner_id = ? OR tm.user_id = ? ORDER BY o.created_at ASC`, userID, userID, ) } if err != nil { return []domain.Organization{}, err } orgs := make([]domain.Organization, 0) for rows.Next() { var o domain.Organization if err := rows.Scan(&o.ID, &o.Name, &o.OwnerID, &o.CreatedAt); err == nil { o.Teams = []domain.Team{} orgs = append(orgs, o) } } _ = rows.Close() for i := range orgs { teams, _ := s.GetTeamsForOrg(orgs[i].ID) orgs[i].Teams = teams } return orgs, nil } 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) if orgID == "" { orgID = "default" } var orgExists int _ = s.db.QueryRow(`SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID).Scan(&orgExists) if orgExists == 0 { var ownerID string _ = s.db.QueryRow(`SELECT id FROM users ORDER BY created_at ASC LIMIT 1`).Scan(&ownerID) if ownerID != "" { _, _ = s.db.Exec( `INSERT OR IGNORE INTO organizations (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`, orgID, "Standard", ownerID, domain.NowISO(), ) } } 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 && strings.Contains(strings.ToUpper(err.Error()), "FOREIGN KEY") { if conn, connErr := s.db.Conn(context.Background()); connErr == nil { _, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = OFF;`) _, err = conn.ExecContext( context.Background(), `INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`, team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt, ) _, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = ON;`) _ = conn.Close() } } 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) 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) 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 } // --- USER INVITES --- func (s *Store) CreateUserInvite(createdBy, email, role, teamID 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, TeamID: strings.TrimSpace(teamID), 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, team_id, created_by, expires_at, used_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`, inv.ID, inv.Code, inv.Email, inv.Role, inv.TeamID, 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.team_id, COALESCE(t.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at FROM user_invites i LEFT JOIN teams t ON t.id = i.team_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.TeamID, &inv.TeamName, &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, team_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.TeamID, &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 } // --- SYSTEM SETTINGS --- func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls 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 _ = s.UpdateSystemSettings(cur) } } func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { settings := &domain.SystemSettings{ SharingMode: "teams_only", 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") } } } 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" } if settings.SMTPPort == "" { settings.SMTPPort = "587" } if settings.SharingMode == "" { settings.SharingMode = "teams_only" } 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', ?) 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, ) return err } // --- 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 }