Files
MindSift/internal/storage/sqlite.go
T
sandrodach 4ef71b61cd
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 16s
feat: complete triage, multi-task lists, kanban, workspace wiki, and admin management
2026-10-24 22:08:43 +02:00

1901 lines
57 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
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,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
is_active INTEGER NOT NULL DEFAULT 1,
discord_webhook_url TEXT NOT NULL DEFAULT '',
notify_before_minutes INTEGER NOT NULL DEFAULT 30,
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,
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 '',
title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📋',
color TEXT NOT NULL DEFAULT '#2563eb',
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,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
FOREIGN KEY(org_id) REFERENCES organizations(id) ON DELETE CASCADE
);
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 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 role TEXT NOT NULL DEFAULT 'member';",
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
"ALTER TABLE users ADD COLUMN discord_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 documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
}
for _, q := range columnMigrations {
_, _ = s.db.Exec(q)
}
// Default system settings
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
('sharing_mode', 'teams_only'),
('allow_registration', 'true'),
('instance_name', 'MindSift')`)
return nil
}
// 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", "admin")
if err != nil {
return s.GetFirstUser()
}
_ = s.SeedStarterContent(user)
return user, nil
}
// --- USERS ---
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
u := &domain.User{}
var isActiveInt int
err := row.Scan(
&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName,
&u.Role, &isActiveInt, &u.DiscordWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
)
if err != nil {
return nil, err
}
u.IsActive = isActiveInt != 0
if u.Role == "" {
u.Role = "member"
}
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) {
if role == "" {
role = "member"
}
u := &domain.User{
ID: domain.NewID("usr"),
Email: strings.ToLower(strings.TrimSpace(email)),
PasswordHash: passwordHash,
DisplayName: strings.TrimSpace(displayName),
Role: role,
IsActive: true,
DiscordWebhookURL: "",
NotifyBeforeMinutes: 30,
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO users (id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at)
VALUES (?, ?, ?, ?, ?, 1, '', 30, ?)`,
u.ID, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.CreatedAt,
)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
row := s.db.QueryRow(
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
FROM users WHERE email = ?`,
strings.ToLower(strings.TrimSpace(email)),
)
return scanUser(row)
}
func (s *Store) GetUserByID(id string) (*domain.User, error) {
row := s.db.QueryRow(
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
FROM users WHERE id = ?`,
id,
)
return scanUser(row)
}
func (s *Store) GetFirstUser() (*domain.User, error) {
row := s.db.QueryRow(
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
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 id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
FROM users ORDER BY created_at ASC`,
)
if err != nil {
return nil, err
}
users := make([]domain.User, 0)
for rows.Next() {
if u, err := scanUser(rows); err == nil {
users = append(users, *u)
}
}
_ = rows.Close()
return users, nil
}
func (s *Store) UpdateUser(userID string, displayName, email, role *string, isActive *bool, discordWebhookURL *string, notifyBeforeMinutes *int) (*domain.User, error) {
u, err := s.GetUserByID(userID)
if err != nil {
return nil, err
}
if displayName != nil {
u.DisplayName = strings.TrimSpace(*displayName)
}
if email != nil {
u.Email = strings.ToLower(strings.TrimSpace(*email))
}
if role != nil && (*role == "admin" || *role == "member") {
u.Role = *role
}
if isActive != nil {
u.IsActive = *isActive
}
if discordWebhookURL != nil {
u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL)
}
if notifyBeforeMinutes != nil {
u.NotifyBeforeMinutes = *notifyBeforeMinutes
}
activeInt := 0
if u.IsActive {
activeInt = 1
}
_, err = s.db.Exec(
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, discord_webhook_url = ?, notify_before_minutes = ? WHERE id = ?`,
u.DisplayName, u.Email, u.Role, activeInt, u.DiscordWebhookURL, 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)
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
}
return t, nil
}
func scanThought(rows interface {
Scan(dest ...any) error
}) (*domain.Thought, error) {
t := &domain.Thought{}
var tagsStr, 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,
)
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)
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, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`,
thoughtID, 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
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
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
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
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, 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
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members 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, 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 := []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 = ?, 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) (*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 *isCompleted {
thought.KanbanStatus = "done"
} else if thought.KanbanStatus == "done" {
thought.KanbanStatus = "todo"
}
}
if dueDate != nil {
thought.DueDate = *dueDate
}
if priority != nil && *priority != "" {
thought.Priority = *priority
}
if taskListID != nil {
thought.TaskListID = *taskListID
}
if kanbanStatus != nil && *kanbanStatus != "" {
thought.KanbanStatus = *kanbanStatus
if *kanbanStatus == "done" {
thought.IsCompleted = true
} else {
thought.IsCompleted = false
}
}
if notes != nil {
thought.Notes = *notes
}
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 = ?, task_list_id = ?, kanban_status = ?, notes = ? WHERE id = ? AND user_id = ?`,
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, 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) 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 {
switch b.Type {
case "h1":
md.WriteString(fmt.Sprintf("# %s\n\n", b.Content))
case "h2":
md.WriteString(fmt.Sprintf("## %s\n\n", b.Content))
case "h3":
md.WriteString(fmt.Sprintf("### %s\n\n", b.Content))
case "todo":
chk := " "
if b.Checked {
chk = "x"
}
md.WriteString(fmt.Sprintf("- [%s] %s\n", chk, b.Content))
case "bullet":
md.WriteString(fmt.Sprintf("- %s\n", b.Content))
case "code":
md.WriteString(fmt.Sprintf("```%s\n%s\n```\n\n", b.Lang, b.Content))
case "callout":
md.WriteString(fmt.Sprintf("> %s\n\n", b.Content))
case "divider":
md.WriteString("---\n\n")
case "table":
md.WriteString(b.Content + "\n\n")
case "progress":
md.WriteString(fmt.Sprintf("<!-- progress: %s -->\n\n", b.Content))
default:
md.WriteString(b.Content + "\n\n")
}
}
} 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
}
s.saveDocumentToDisk(doc)
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) {
rows, err := s.db.Query(
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, 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, folder_path, 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, folderPath *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 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
}
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 = ?, folder_path = ?, is_pinned = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.UpdatedAt, docID, userID,
)
if err != nil {
return nil, err
}
s.saveDocumentToDisk(doc)
return doc, nil
}
func (s *Store) DeleteDocument(userID, docID string) error {
doc, _ := s.GetDocumentByID(userID, docID)
if doc != nil {
s.removeDocumentFromDisk(doc)
}
_, 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, task_list_id, kanban_status, notes, alarm_sent, 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, folder_path, 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)
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, title, icon, color string) (*domain.TaskList, error) {
if strings.TrimSpace(title) == "" {
title = "Meine Aufgaben"
}
if icon == "" {
icon = "📋"
}
if color == "" {
color = "#2563eb"
}
now := domain.NowISO()
tl := &domain.TaskList{
ID: domain.NewID("tkl"),
UserID: userID,
SpaceID: spaceID,
Title: strings.TrimSpace(title),
Icon: icon,
Color: color,
CreatedAt: now,
UpdatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO task_lists (id, user_id, space_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt,
)
if err != nil {
return nil, err
}
return tl, nil
}
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
rows, err := s.db.Query(
`SELECT id, user_id, space_id, title, icon, color, created_at, updated_at
FROM task_lists
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,
)
if err != nil {
return nil, err
}
defer rows.Close()
var list []domain.TaskList
for rows.Next() {
var tl domain.TaskList
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
_ = 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)
list = append(list, tl)
}
}
return list, nil
}
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
var tl domain.TaskList
err := s.db.QueryRow(
`SELECT id, user_id, space_id, title, icon, color, created_at, updated_at
FROM task_lists WHERE id = ? AND (user_id = ? OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`,
listID, userID, userID,
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt)
if err != nil {
return nil, err
}
_ = 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)
return &tl, nil
}
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string) (*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
}
tl.UpdatedAt = domain.NowISO()
_, err = s.db.Exec(
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.UpdatedAt, listID, userID,
)
if err != nil {
return nil, err
}
return tl, nil
}
func (s *Store) DeleteTaskList(userID, listID string) error {
_, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' 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,
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) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
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 nil, err
}
defer rows.Close()
var orgs []domain.Organization
for rows.Next() {
var o domain.Organization
if err := rows.Scan(&o.ID, &o.Name, &o.OwnerID, &o.CreatedAt); err == nil {
orgs = append(orgs, o)
}
}
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")
}
team := &domain.Team{
ID: domain.NewID("team"),
OrgID: orgID,
Name: name,
Description: strings.TrimSpace(description),
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
)
if err != nil {
return nil, err
}
return team, nil
}
func (s *Store) 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 nil, err
}
defer rows.Close()
var teams []domain.Team
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, _ = s.GetTeamMembers(t.ID)
teams = append(teams, t)
}
}
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 nil, err
}
defer rows.Close()
var teams []domain.Team
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, _ = s.GetTeamMembers(t.ID)
teams = append(teams, t)
}
}
return teams, nil
}
func (s *Store) AddTeamMember(teamID, userID, role string) error {
if role == "" {
role = "editor"
}
_, 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 nil, err
}
defer rows.Close()
var members []domain.TeamMember
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
}
// --- SYSTEM SETTINGS ---
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
settings := &domain.SystemSettings{
SharingMode: "teams_only",
AllowRegistration: true,
InstanceName: "MindSift",
}
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
}
}
}
return settings, nil
}
func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
regStr := "false"
if settings.AllowRegistration {
regStr = "true"
}
_, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES
('sharing_mode', ?),
('allow_registration', ?),
('instance_name', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
settings.SharingMode, regStr, settings.InstanceName,
)
return err
}
// --- ALARMS ---
type DueTaskAlarm struct {
TaskID string
UserID string
Title string
DueDate string
Priority string
DiscordWebhookURL string
}
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
rows, err := s.db.Query(
`SELECT t.id, t.user_id, t.content, t.due_date, t.priority, u.discord_webhook_url
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.discord_webhook_url != ''`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var alarms []DueTaskAlarm
for rows.Next() {
var a DueTaskAlarm
if err := rows.Scan(&a.TaskID, &a.UserID, &a.Title, &a.DueDate, &a.Priority, &a.DiscordWebhookURL); err == nil {
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
}