Files
MindSift/internal/storage/sqlite.go
T
sandrodach 5c4e130862
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 38s
fix(sqlite,browser): resolve SQLite connection pool deadlock, disable stale SW cache, and update BRAIN.md
2026-10-24 21:27:51 +02:00

1253 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storage
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
_ "modernc.org/sqlite"
)
type Store struct {
db *sql.DB
initMu sync.Mutex
}
func NewStore(dbPath string) (*Store, error) {
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)
}
}
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}
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) {
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("admin@mindsift.local", hash, "Sandro")
if err != nil {
return s.GetFirstUser()
}
_ = 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
}
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")
}
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 {
for tRows.Next() {
if t, err := scanThought(tRows); err == nil {
thoughts = append(thoughts, *t)
}
}
_ = tRows.Close()
}
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 {
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)
return &domain.FullExport{
ExportedAt: domain.NowISO(),
Version: "1.0.0",
User: *user,
Thoughts: thoughts,
Documents: docs,
Spaces: spaces,
Lists: lists,
}, nil
}