package storage import ( "database/sql" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" _ "modernc.org/sqlite" ) type Store struct { db *sql.DB } func NewStore(dbPath string) (*Store, error) { 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) } dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)" db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("failed to open sqlite db: %w", err) } db.SetMaxOpenConns(1) // SQLite WAL optimal writer serialization in single process db.SetMaxIdleConns(1) store := &Store{db: db} 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, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, 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 '', 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 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 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 '[]', 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 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 ); ` _, err := s.db.Exec(schema) return err } // SeedStarterContent populates realistic initial content for a newly created user func (s *Store) SeedStarterContent(user *domain.User) error { now := domain.NowISO() // 1. Starter Thoughts in Inbox for Tinder-Style Triage 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", }, { content: "Buch-Empfehlung aus dem Podcast: 'The Pragmatic Programmer' nochmal lesen #lesen", priority: "low", source: "manual", }, { content: "Steuerunterlagen für Q3 an den Steuerberater schicken #finanzen #büro", priority: "high", source: "manual", }, } for _, st := range starterThoughts { _, _ = s.CreateThought(user.ID, st.content, st.priority, st.source, st.dueDate) } // Create one already kept Task with due date 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]) } // 2. Create Shared Family Space & Shopping List 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 { items := []struct { content string category string quantity string done bool }{ {"Hafermilch (Barista Edition)", "Kühlregal", "3x", false}, {"San Marzano Dosentomaten", "Konserven", "2 Dosen", false}, {"Frischer Basilikum & Knoblauch", "Obst & Gemüse", "1 Bund", false}, {"Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", false}, {"Spülmaschinentabs", "Drogerie", "1 Packung", true}, } for idx, it := range items { created, err := s.CreateListItem(shopList.ID, it.content, it.category, it.quantity, idx) if err == nil && it.done && created != nil { _, _ = s.ToggleListItem(created.ID, true, user.ID, user.DisplayName) } } } vacationList, err := s.CreateList(familySpace.ID, "Packliste Südtirol-Trip", "🏔️", "vacation") if err == nil && vacationList != nil { _, _ = s.CreateListItem(vacationList.ID, "Wanderschuhe imprägnieren", "Ausrüstung", "", 0) _, _ = s.CreateListItem(vacationList.ID, "Powerbank & Ladekabel", "Technik", "2x", 1) _, _ = s.CreateListItem(vacationList.ID, "Vignette Österreich online kaufen", "Dokumente", "", 2) } } // 3. Starter Workspace Document (Notion-Flair) 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 – ganz ohne Datenbank-Ballast."}, {ID: "blk_2", Type: "h2", Content: "Wie funktioniert der Workflow?"}, {ID: "blk_3", Type: "bullet", Content: "1. Capture First: Drücke jederzeit 'C' oder '/' und tippe oder diktiere deinen Gedanken in unter 3 Sekunden."}, {ID: "blk_4", Type: "bullet", Content: "2. Tinder-Style Triage: Wische Karten im Triage-Deck nach links (Verwerfen) oder rechts (Behalten / Aufgabe / Dokument)."}, {ID: "blk_5", Type: "bullet", Content: "3. Echtzeit-Listen: Teile deinen Space per Invite-Code mit Partner oder Familie. Abgehakte Einkäufe synchronisieren sich sofort via WebSocket."}, {ID: "blk_6", Type: "h2", Content: "Projekt-Checkliste: Homelab Upgrade"}, {ID: "blk_7", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true}, {ID: "blk_8", Type: "todo", Content: "Home Assistant Webhook für Rauchmelder & Luftfeuchtigkeit anbinden", Checked: false}, {ID: "blk_9", Type: "todo", Content: "PWA auf dem Smartphone-Homescreen installieren", Checked: false}, {ID: "blk_10", Type: "code", Content: "curl -X POST http://localhost:8080/api/v1/webhooks/ingest?token=DEIN_KEY \\\n -H 'Content-Type: application/json' \\\n -d '{\"title\": \"Waschmaschine im Keller ist fertig!\", \"priority\": \"high\", \"tags\": [\"smarthome\"]}'", Lang: "bash"}, } _, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Homelab Notizen", "🚀", starterBlocks, []string{"homelab", "mindsift", "workflow"}, true) return nil } // EnsureDefaultUser creates a default user if the database has 0 users so self-hosted users can jump right in func (s *Store) EnsureDefaultUser() (*domain.User, error) { 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("admin@mindsift.local", hash, "Sandro") if err != nil { return nil, err } _ = s.SeedStarterContent(user) return user, nil } // --- USERS --- 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 string) (*domain.User, error) { u := &domain.User{ ID: domain.NewID("usr"), Email: strings.ToLower(strings.TrimSpace(email)), PasswordHash: passwordHash, DisplayName: strings.TrimSpace(displayName), CreatedAt: domain.NowISO(), } _, err := s.db.Exec( `INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`, u.ID, u.Email, u.PasswordHash, u.DisplayName, u.CreatedAt, ) if err != nil { return nil, err } return u, nil } func (s *Store) GetUserByEmail(email string) (*domain.User, error) { u := &domain.User{} err := s.db.QueryRow( `SELECT id, email, password_hash, display_name, created_at FROM users WHERE email = ?`, strings.ToLower(strings.TrimSpace(email)), ).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &u.CreatedAt) if err != nil { return nil, err } return u, nil } func (s *Store) GetUserByID(id string) (*domain.User, error) { u := &domain.User{} err := s.db.QueryRow( `SELECT id, email, password_hash, display_name, created_at FROM users WHERE id = ?`, id, ).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &u.CreatedAt) if err != nil { return nil, err } return u, nil } func (s *Store) GetFirstUser() (*domain.User, error) { u := &domain.User{} err := s.db.QueryRow( `SELECT id, email, password_hash, display_name, created_at FROM users ORDER BY created_at ASC LIMIT 1`, ).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &u.CreatedAt) if err != nil { return nil, err } return u, nil } // --- THOUGHTS & TRIAGE --- func (s *Store) CreateThought(userID, content, priority, source, dueDate 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" } 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, CreatedAt: domain.NowISO(), } _, err := s.db.Exec( `INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.CreatedAt, ) if err != nil { return nil, err } return t, nil } func scanThought(rows interface { Scan(dest ...any) error }) (*domain.Thought, error) { t := &domain.Thought{} var tagsStr, statusStr string var isCompleted int err := rows.Scan( &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, &t.DueDate, &t.CreatedAt, &t.TriagedAt, ) if err != nil { return nil, err } t.Status = domain.ThoughtStatus(statusStr) t.IsCompleted = isCompleted != 0 t.Tags = []string{} _ = json.Unmarshal([]byte(tagsStr), &t.Tags) return t, nil } 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, created_at, triaged_at FROM thoughts WHERE id = ? AND user_id = ?`, thoughtID, 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, created_at, triaged_at 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, created_at, triaged_at FROM thoughts WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit, ) } if err != nil { return nil, err } defer rows.Close() result := make([]domain.Thought, 0) for rows.Next() { t, err := scanThought(rows) if err == nil { result = append(result, *t) } } return result, nil } func (s *Store) GetTasks(userID string) ([]domain.Thought, error) { rows, err := s.db.Query( `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, created_at, triaged_at FROM thoughts WHERE user_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, ) if err != nil { return nil, err } defer rows.Close() result := make([]domain.Thought, 0) for rows.Next() { t, err := scanThought(rows) if err == nil { result = append(result, *t) } } 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 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 } 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 := []domain.DocumentBlock{ {ID: domain.NewID("blk"), Type: "callout", Content: "Ursprünglicher Gedanke aus deiner Inbox vom " + thought.CreatedAt[:10]}, {ID: domain.NewID("blk"), Type: "paragraph", Content: thought.Content}, {ID: domain.NewID("blk"), Type: "h2", Content: "Nächste Schritte"}, {ID: domain.NewID("blk"), Type: "todo", Content: "Ersten Meilenstein definieren", Checked: false}, } 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 = ?, triaged_at = ? WHERE id = ? AND user_id = ?`, string(newStatus), convertedType, convertedRefID, dueDate, 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) (*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 isCompleted != nil { thought.IsCompleted = *isCompleted } if dueDate != nil { thought.DueDate = *dueDate } if priority != nil && *priority != "" { thought.Priority = *priority } tagsJSON, _ := json.Marshal(thought.Tags) completedInt := 0 if thought.IsCompleted { completedInt = 1 } _, err = s.db.Exec( `UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ? WHERE id = ? AND user_id = ?`, thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thoughtID, userID, ) if err != nil { return nil, err } return thought, nil } func (s *Store) DeleteThought(userID, thoughtID string) error { _, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ? AND user_id = ?`, thoughtID, userID) 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) CreateDocument(userID, spaceID, title, icon 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, IsPinned: isPinned, CreatedAt: now, UpdatedAt: now, } _, err := s.db.Exec( `INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, 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), pinnedInt, doc.CreatedAt, doc.UpdatedAt, ) if err != nil { return nil, err } 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, &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) { rows, err := s.db.Query( `SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, is_pinned, is_archived, created_at, updated_at FROM documents WHERE user_id = ? AND is_archived = 0 ORDER BY is_pinned DESC, updated_at DESC`, userID, ) if err != nil { return nil, err } defer rows.Close() docs := make([]domain.Document, 0) for rows.Next() { d, err := scanDocument(rows) if err == nil { docs = append(docs, *d) } } return docs, nil } func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) { row := s.db.QueryRow( `SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, is_pinned, is_archived, created_at, updated_at FROM documents WHERE id = ? AND user_id = ?`, docID, userID, ) return scanDocument(row) } func (s *Store) UpdateDocument(userID, docID string, title, icon *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) { doc, err := s.GetDocumentByID(userID, docID) if err != nil { return nil, err } if title != nil { doc.Title = strings.TrimSpace(*title) } if icon != nil && *icon != "" { doc.Icon = *icon } 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 } if isPinned != nil { doc.IsPinned = *isPinned } doc.UpdatedAt = domain.NowISO() blocksJSON, _ := json.Marshal(doc.Blocks) tagsJSON, _ := json.Marshal(doc.Tags) pinnedInt := 0 if doc.IsPinned { pinnedInt = 1 } _, err = s.db.Exec( `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, is_pinned = ?, updated_at = ? WHERE id = ? AND user_id = ?`, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), pinnedInt, doc.UpdatedAt, docID, userID, ) if err != nil { return nil, err } return doc, nil } func (s *Store) DeleteDocument(userID, docID string) error { _, err := s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID) return err } // --- 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) 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 } defer rows.Close() 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 sp.Members, _ = s.GetSpaceMembers(sp.ID) _ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, sp.ID).Scan(&sp.ListsCount) spaces = append(spaces, sp) } } 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 } defer rows.Close() 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 { l.Items, _ = s.GetListItems(l.ID) lists = append(lists, l) } } 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 } defer rows.Close() 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 { l.Items, _ = s.GetListItems(l.ID) lists = append(lists, l) } } 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") } now := domain.NowISO() it := &domain.ListItem{ ID: domain.NewID("itm"), ListID: listID, Content: content, Category: strings.TrimSpace(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) 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, created_at, triaged_at 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 { defer tRows.Close() for tRows.Next() { if t, err := scanThought(tRows); err == nil { thoughts = append(thoughts, *t) } } } dRows, err := s.db.Query( `SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, is_pinned, is_archived, created_at, updated_at FROM documents WHERE user_id = ? AND (LOWER(title) LIKE ? OR LOWER(content) LIKE ?) ORDER BY updated_at DESC LIMIT 20`, userID, q, q, ) docs := make([]domain.Document, 0) if err == nil { defer dRows.Close() for dRows.Next() { if d, err := scanDocument(dRows); err == nil { docs = append(docs, *d) } } } 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) return &domain.FullExport{ ExportedAt: domain.NowISO(), Version: "1.0.0", User: *user, Thoughts: thoughts, Documents: docs, Spaces: spaces, Lists: lists, }, nil }