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, 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 spaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🏠', description TEXT NOT NULL DEFAULT '', invite_code TEXT UNIQUE NOT NULL, is_personal INTEGER NOT NULL DEFAULT 0, 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 space_members ( space_id TEXT NOT NULL, user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor', joined_at TEXT NOT NULL, PRIMARY KEY (space_id, user_id), FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); 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 'editor', 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, space_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(space_id) REFERENCES spaces(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC); 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 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 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);", } for _, q := range columnMigrations { _, _ = s.db.Exec(q) } // 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() } } // 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], "") } familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false) if err == nil && familySpace != nil { shopList, err := s.CreateList(familySpace.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, 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.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 != "team_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, 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, 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" || viewer.Role == "team_admin" || mode == "open" { return s.GetAllUsers() } if mode == "strict_private" { return []domain.User{*viewer}, nil } // teams_only: if no teams exist yet, allow discovering users on this instance var teamCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teamCount) if teamCount == 0 { return s.GetAllUsers() } // 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 = ? UNION SELECT sm2.user_id FROM space_members sm1 JOIN space_members sm2 ON sm1.space_id = sm2.space_id WHERE sm1.user_id = ? )`, viewerID, 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) } func (s *Store) UpdateUserFull( userID string, displayName, email, role *string, isActive *bool, alarmChannels *[]string, alarmEmail, discordWebhookURL, customWebhookURL *string, notifyBeforeMinutes *int, ) (*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 == "team_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 } 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 = ? WHERE id = ?`, u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, 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 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, ) if err != nil { return nil, err } 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 = ?)) ) OR (space_id != '' AND space_id IN (SELECT space_id FROM space_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 FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, thoughtID, userID, 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 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, 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 FROM thoughts WHERE user_id = ? ORDER BY 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 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, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`, userID, 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 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, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`, userID, 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 } // 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) --- func (s *Store) saveDocumentToDisk(doc *domain.Document) { if s.docDir == "" || doc == nil { return } userDir := filepath.Join(s.docDir, doc.UserID) if doc.FolderPath != "" { userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath)) } _ = os.MkdirAll(userDir, 0755) var md strings.Builder md.WriteString(fmt.Sprintf("# %s %s\n\n", doc.Icon, doc.Title)) if len(doc.Blocks) > 0 { for _, b := range doc.Blocks { indent := strings.Repeat(" ", b.Level) switch b.Type { case "h1": md.WriteString(fmt.Sprintf("%s- # %s\n", indent, b.Content)) case "h2": md.WriteString(fmt.Sprintf("%s- ## %s\n", indent, b.Content)) case "h3": md.WriteString(fmt.Sprintf("%s- ### %s\n", indent, b.Content)) case "todo": chk := " " if b.Checked { chk = "x" } formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") md.WriteString(fmt.Sprintf("%s- [%s] %s\n", indent, chk, formatted)) case "bullet", "paragraph": formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") md.WriteString(fmt.Sprintf("%s- %s\n", indent, formatted)) case "code": md.WriteString(fmt.Sprintf("%s- ```%s\n%s %s\n%s ```\n", indent, b.Lang, indent, b.Content, indent)) case "callout": formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" > ") md.WriteString(fmt.Sprintf("%s- > %s\n", indent, formatted)) case "divider": md.WriteString(fmt.Sprintf("%s- ---\n", indent)) case "table": md.WriteString(fmt.Sprintf("%s- %s\n", indent, b.Content)) case "progress": md.WriteString(fmt.Sprintf("%s- \n", indent, b.Content)) default: formatted := strings.ReplaceAll(b.Content, "\n", "\n"+indent+" ") md.WriteString(fmt.Sprintf("%s- %s\n", indent, formatted)) } } } else if doc.Content != "" { md.WriteString(doc.Content + "\n\n") } targetPath := filepath.Join(userDir, doc.ID+".md") _ = os.WriteFile(targetPath, []byte(md.String()), 0644) } func (s *Store) removeDocumentFromDisk(doc *domain.Document) { if s.docDir == "" || doc == nil { return } userDir := filepath.Join(s.docDir, doc.UserID) if doc.FolderPath != "" { userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath)) } targetPath := filepath.Join(userDir, doc.ID+".md") _ = os.Remove(targetPath) } 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: ""}, } } if tags == nil { tags = []string{} } 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 } 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: folderPath, IsPinned: isPinned, CreatedAt: now, UpdatedAt: now, } _, err := s.db.Exec( `INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt, ) 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.saveDocumentToDisk(doc) _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) return doc, nil } 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, ) 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) return d, nil } func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { // 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 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 } docs := make([]domain.Document, 0) for rows.Next() { d, err := scanDocument(rows) if err == nil { d.Permission = "owner" d.IsOwner = true docs = append(docs, *d) } } _ = rows.Close() // 2. Fetch documents shared with the user (with per-user is_pinned from doc_pins) 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, s.permission, u.display_name FROM documents d JOIN doc_shares s ON d.id = s.doc_id JOIN users u ON d.user_id = u.id LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? WHERE s.user_id = ? AND d.is_archived = 0 ORDER BY is_pinned DESC, d.updated_at DESC`, userID, userID, ) if err == nil { 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, &perm, &ownerName, ); scanErr == nil { 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.Permission = perm d.IsOwner = false d.OwnerName = ownerName docs = append(docs, *d) } } _ = sharedRows.Close() } // 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) { // 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 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 := scanDocument(row) if err == nil { doc.Permission = "owner" doc.IsOwner = true if shares, err := s.GetDocShares(doc.ID); err == nil { doc.SharedWith = shares } return doc, nil } // Try shared document (with per-user is_pinned) 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, s.permission, u.display_name FROM documents d JOIN doc_shares s ON d.id = s.doc_id JOIN users u ON d.user_id = u.id LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? WHERE d.id = ? AND s.user_id = ?`, userID, docID, 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, &perm, &ownerName, ) 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.Permission = perm d.IsOwner = false d.OwnerName = ownerName 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 if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange { 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 title != nil { doc.Title = strings.TrimSpace(*title) } if icon != nil && *icon != "" { doc.Icon = *icon } if folderPath != nil { doc.FolderPath = *folderPath } if blocks != nil { doc.Blocks = *blocks var plainBuilder strings.Builder for _, b := range doc.Blocks { if b.Content != "" { plainBuilder.WriteString(b.Content) plainBuilder.WriteString("\n") } } doc.Content = plainBuilder.String() } if tags != nil { doc.Tags = *tags } doc.UpdatedAt = domain.NowISO() blocksJSON, _ := json.Marshal(doc.Blocks) tagsJSON, _ := json.Marshal(doc.Tags) _, err = s.db.Exec( `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, updated_at = ? WHERE id = ?`, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, doc.UpdatedAt, docID, ) if err != nil { return nil, err } s.saveDocumentToDisk(doc) 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 return s.DeleteDocShare(userID, docID, userID) } if doc != nil { s.removeDocumentFromDisk(doc) } _, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID) _, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID) 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 { var blocks []domain.DocumentBlock if err := json.Unmarshal([]byte(d.blocksJSON), &blocks); err == nil && 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) saveTagPageToDisk(userID, tag, content string) { if s.docDir == "" || tag == "" { return } tagDir := filepath.Join(filepath.Dir(s.docDir), "tag_pages", userID) _ = os.MkdirAll(tagDir, 0755) safeTag := regexp.MustCompile(`[^a-zA-Z0-9äöüÄÖÜß_-]+`).ReplaceAllString(tag, "_") targetPath := filepath.Join(tagDir, safeTag+".md") if strings.TrimSpace(content) == "" { _ = os.Remove(targetPath) return } md := fmt.Sprintf("# #%s\n\n%s\n", tag, content) _ = os.WriteFile(targetPath, []byte(md), 0644) } 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 } s.saveTagPageToDisk(userID, cleanTag, content) 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 = ? WHERE (d.user_id = ? OR s.user_id = ?) AND d.is_archived = 0 GROUP BY t.tag ORDER BY count DESC, t.tag ASC`, 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, '') 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 = ? WHERE (d.user_id = ? OR s.user_id = ?) AND t.tag = ? AND d.is_archived = 0 ORDER BY d.updated_at DESC`, 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] var blocks []domain.DocumentBlock _ = json.Unmarshal([]byte(first.blocksJSON), &blocks) 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 SQLite documents (owned by user or shared with user) rows, err := s.db.Query( `SELECT DISTINCT folder_path FROM documents WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?)) AND is_archived = 0 AND folder_path != ''`, userID, userID, ) if err == nil { for rows.Next() { var fp string if err := rows.Scan(&fp); err == nil && fp != "" { clean := filepath.ToSlash(filepath.Clean(fp)) parts := strings.Split(clean, "/") var cur string for _, p := range parts { if p != "" && p != "." { if cur == "" { cur = p } else { cur = cur + "/" + p } folderSet[cur] = true } } } } _ = rows.Close() } // 2. Scan physical directories on disk in docDir/userID if s.docDir != "" { userDir := filepath.Join(s.docDir, userID) if fi, err := os.Stat(userDir); err == nil && fi.IsDir() { _ = filepath.Walk(userDir, func(path string, info os.FileInfo, err error) error { if err != nil || !info.IsDir() { return nil } if path == userDir { return nil } rel, err := filepath.Rel(userDir, path) if err == nil && rel != "" && rel != "." { clean := filepath.ToSlash(filepath.Clean(rel)) parts := strings.Split(clean, "/") var cur string for _, p := range parts { if p != "" && p != "." { if cur == "" { cur = p } else { cur = cur + "/" + p } folderSet[cur] = true } } } return nil }) } } result := make([]string, 0, len(folderSet)) for f := range folderSet { result = append(result, f) } sort.Strings(result) return result, nil } 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") } if s.docDir != "" { targetDir := filepath.Join(s.docDir, userID, clean) if err := os.MkdirAll(targetDir, 0755); err != nil { return "", err } } 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") } // Find all documents in this folder and subfolders rows, err := s.db.Query( `SELECT id FROM documents WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%", ) if err != nil { return nil, err } defer rows.Close() var deletedDocIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err == nil { deletedDocIDs = append(deletedDocIDs, id) } } _ = rows.Close() // Delete from SQLite, pins, shares, tags for _, id := range deletedDocIDs { _, _ = 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 documents WHERE id = ? AND user_id = ?`, id, userID) } // Remove physical folder and subdirectories from disk if s.docDir != "" { targetDir := filepath.Join(s.docDir, userID, clean) _ = os.RemoveAll(targetDir) } return deletedDocIDs, nil } func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) { rows, 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 { return nil, err } defer rows.Close() shares := make([]domain.DocShare, 0) for rows.Next() { var sh domain.DocShare if err := rows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { shares = append(shares, sh) } } return shares, 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") } if permission != "read" && permission != "write" { permission = "read" } // Resolve target user by ID, username, or Email var targetUser *domain.User cleanTarget := strings.TrimSpace(targetUserIdentifier) 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 { 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, UserID: targetUser.ID, UserEmail: targetUser.Email, DisplayName: targetUser.DisplayName, Permission: permission, CreatedBy: currentUserID, CreatedAt: now, }, nil } func (s *Store) DeleteDocShare(currentUserID, docID, targetUserID string) error { doc, err := s.GetDocumentByID(currentUserID, docID) if err != nil { return err } if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetUserID { return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe") } _, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND user_id = ?`, docID, targetUserID) return err } func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { rows, err := s.db.Query( `SELECT id, email, display_name, role, is_active, 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.CreatedAt); err == nil { u.IsActive = activeInt == 1 users = append(users, u) } } return users, nil } // --- SPACES & REALTIME LISTS --- func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) { if strings.TrimSpace(name) == "" { return nil, errors.New("space name required") } if icon == "" { icon = "🏠" } now := domain.NowISO() inviteCode := strings.ToUpper(domain.NewID("inv")[4:12]) personalInt := 0 if isPersonal { personalInt = 1 } sp := &domain.Space{ ID: domain.NewID("spc"), Name: strings.TrimSpace(name), Icon: icon, Description: strings.TrimSpace(description), InviteCode: inviteCode, IsPersonal: isPersonal, OwnerID: ownerID, CreatedAt: now, } _, err := s.db.Exec( `INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt, ) if err != nil { return nil, err } _, _ = s.db.Exec( `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`, sp.ID, ownerID, now, ) return s.GetSpaceByID(sp.ID) } func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) { inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode)) var spaceID string err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID) if err != nil { return nil, errors.New("Ungültiger Einladungscode") } _, err = s.db.Exec( `INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`, spaceID, userID, domain.NowISO(), ) if err != nil { return nil, err } return s.GetSpaceByID(spaceID) } func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) { sp := &domain.Space{} var personalInt int err := s.db.QueryRow( `SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`, spaceID, ).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt) if err != nil { return nil, err } sp.IsPersonal = personalInt != 0 sp.Members, _ = s.GetSpaceMembers(spaceID) _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount) return sp, nil } func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) { rows, err := s.db.Query( `SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at FROM space_members sm JOIN users u ON u.id = sm.user_id WHERE sm.space_id = ? ORDER BY sm.joined_at ASC`, spaceID, ) if err != nil { return nil, err } defer rows.Close() members := make([]domain.SpaceMember, 0) for rows.Next() { var m domain.SpaceMember if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil { members = append(members, m) } } return members, nil } func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, role string) (*domain.SpaceMember, error) { // 1. Check if current user is member of space var myRole string err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) if err != nil { return nil, errors.New("keine Berechtigung für diesen Bereich") } // 2. Resolve target user by ID, username or email cleanTarget := strings.TrimSpace(targetUserIdentifier) 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 { return nil, errors.New("benutzer nicht gefunden") } if role != "owner" && role != "editor" && role != "viewer" { role = "editor" } now := domain.NowISO() _, err = s.db.Exec( `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?) ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`, spaceID, targetUser.ID, role, now, ) if err != nil { return nil, err } return &domain.SpaceMember{ SpaceID: spaceID, UserID: targetUser.ID, DisplayName: targetUser.DisplayName, Email: targetUser.Email, Role: role, JoinedAt: now, }, nil } func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error { var myRole string err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole) if err != nil { return errors.New("keine Berechtigung für diesen Bereich") } if currentUserID != targetUserID && myRole != "owner" { return errors.New("nur der Eigentümer kann andere Mitglieder entfernen") } var targetRole string _ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID).Scan(&targetRole) if targetRole == "owner" { var ownerCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount) if ownerCount <= 1 { return errors.New("der letzte Eigentümer kann den Bereich nicht verlassen") } } _, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID) return err } func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) { rows, err := s.db.Query( `SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at FROM spaces s JOIN space_members sm ON sm.space_id = s.id WHERE sm.user_id = ? ORDER BY s.created_at ASC`, userID, ) if err != nil { return nil, err } spaces := make([]domain.Space, 0) for rows.Next() { var sp domain.Space var personalInt int if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil { sp.IsPersonal = personalInt != 0 spaces = append(spaces, sp) } } _ = rows.Close() for i := range spaces { spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID) _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount) } return spaces, nil } func (s *Store) CreateList(spaceID, 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"), SpaceID: spaceID, Title: strings.TrimSpace(title), Icon: icon, ListType: listType, Items: []domain.ListItem{}, UpdatedAt: now, CreatedAt: now, } _, err := s.db.Exec( `INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, ) if err != nil { return nil, err } return lst, nil } func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) { rows, err := s.db.Query( `SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE space_id = ? ORDER BY created_at ASC`, spaceID, ) if err != nil { return nil, err } lists := make([]domain.SharedList, 0) for rows.Next() { var l domain.SharedList if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil { lists = append(lists, l) } } _ = rows.Close() for i := range lists { lists[i].Items, _ = s.GetListItems(lists[i].ID) } return lists, nil } func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { rows, err := s.db.Query( `SELECT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at FROM lists l JOIN space_members sm ON sm.space_id = l.space_id WHERE sm.user_id = ? ORDER BY l.updated_at DESC`, userID, ) if err != nil { return nil, err } lists := make([]domain.SharedList, 0) for rows.Next() { var l domain.SharedList if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil { lists = append(lists, l) } } _ = rows.Close() for i := range lists { lists[i].Items, _ = s.GetListItems(lists[i].ID) } return lists, nil } func (s *Store) GetListByID(listID string) (*domain.SharedList, error) { var l domain.SharedList err := s.db.QueryRow( `SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`, listID, ).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt) if err != nil { return nil, err } l.Items, _ = s.GetListItems(l.ID) return &l, nil } func (s *Store) DeleteList(listID string) error { _, 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() } // --- 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(*) FROM lists l JOIN space_members sm ON sm.space_id = l.space_id WHERE sm.user_id = ?`, 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 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 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) spaces, _ := s.GetSpacesForUser(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, Spaces: spaces, 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 NOTHING`, share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy, ) if err != nil { return nil, err } 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 FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email) } } return shares, nil } func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember { memberMap := make(map[string]domain.TaskListMember) // 1. Owner var ownerName, ownerEmail string _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail) memberMap[tl.UserID] = domain.TaskListMember{ UserID: tl.UserID, DisplayName: ownerName, Email: ownerEmail, Role: "owner", } // 2. Team members if tl.TeamID != "" if tl.TeamID != "" { rows, err := s.db.Query( `SELECT u.id, u.display_name, u.email 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); 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 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); 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 FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email); 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 = ?)) ) OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)) ORDER BY updated_at DESC`, userID, 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 = ?)) ) OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)) )`, listID, userID, 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" || viewer.Role == "team_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) 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) AddTeamMember(teamID, userID, role string) error { if role == "" { 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) RemoveTeamMember(teamID, userID string) error { _, 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, 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.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 != "team_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 }