1232 lines
37 KiB
Go
1232 lines
37 KiB
Go
package storage
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/sandrodach/mindsift/internal/auth"
|
||
"github.com/sandrodach/mindsift/internal/domain"
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
type Store struct {
|
||
db *sql.DB
|
||
}
|
||
|
||
func NewStore(dbPath string) (*Store, error) {
|
||
dir := filepath.Dir(dbPath)
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err)
|
||
}
|
||
|
||
db, err := sql.Open("sqlite", dbPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
|
||
}
|
||
|
||
db.SetMaxOpenConns(1) // SQLite WAL optimal writer serialization in single process
|
||
db.SetMaxIdleConns(1)
|
||
|
||
store := &Store{db: db}
|
||
if err := store.migrate(); err != nil {
|
||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||
}
|
||
|
||
return store, nil
|
||
}
|
||
|
||
func (s *Store) Close() error {
|
||
return s.db.Close()
|
||
}
|
||
|
||
func (s *Store) migrate() error {
|
||
schema := `
|
||
PRAGMA journal_mode = WAL;
|
||
PRAGMA synchronous = NORMAL;
|
||
PRAGMA foreign_keys = ON;
|
||
PRAGMA busy_timeout = 5000;
|
||
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY,
|
||
email TEXT UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL,
|
||
display_name TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS thoughts (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
tags TEXT NOT NULL DEFAULT '[]',
|
||
priority TEXT NOT NULL DEFAULT 'normal',
|
||
source TEXT NOT NULL DEFAULT 'manual',
|
||
status TEXT NOT NULL DEFAULT 'inbox',
|
||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||
converted_type TEXT DEFAULT '',
|
||
converted_ref_id TEXT DEFAULT '',
|
||
due_date TEXT DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
triaged_at TEXT DEFAULT '',
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
|
||
|
||
CREATE TABLE IF NOT EXISTS spaces (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '🏠',
|
||
description TEXT NOT NULL DEFAULT '',
|
||
invite_code TEXT UNIQUE NOT NULL,
|
||
is_personal INTEGER NOT NULL DEFAULT 0,
|
||
owner_id TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS space_members (
|
||
space_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'editor',
|
||
joined_at TEXT NOT NULL,
|
||
PRIMARY KEY (space_id, user_id),
|
||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS documents (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
space_id TEXT DEFAULT '',
|
||
title TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '📄',
|
||
content TEXT NOT NULL DEFAULT '',
|
||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||
tags TEXT NOT NULL DEFAULT '[]',
|
||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS lists (
|
||
id TEXT PRIMARY KEY,
|
||
space_id TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '🛒',
|
||
list_type TEXT NOT NULL DEFAULT 'shopping',
|
||
updated_at TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS list_items (
|
||
id TEXT PRIMARY KEY,
|
||
list_id TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
category TEXT NOT NULL DEFAULT '',
|
||
quantity TEXT NOT NULL DEFAULT '',
|
||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||
completed_by TEXT NOT NULL DEFAULT '',
|
||
completed_by_name TEXT NOT NULL DEFAULT '',
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
completed_at TEXT NOT NULL DEFAULT '',
|
||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
|
||
|
||
CREATE TABLE IF NOT EXISTS api_keys (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
key_prefix TEXT NOT NULL,
|
||
key_hash TEXT UNIQUE NOT NULL,
|
||
scopes TEXT NOT NULL DEFAULT 'ingest,read',
|
||
last_used_at TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
`
|
||
_, err := s.db.Exec(schema)
|
||
return err
|
||
}
|
||
|
||
// SeedStarterContent populates realistic initial content for a newly created user
|
||
func (s *Store) SeedStarterContent(user *domain.User) error {
|
||
now := domain.NowISO()
|
||
|
||
// 1. Starter Thoughts in Inbox for Tinder-Style Triage
|
||
starterThoughts := []struct {
|
||
content string
|
||
priority string
|
||
source string
|
||
dueDate string
|
||
}{
|
||
{
|
||
content: "Zündkerzen und Luftfilter für den Rasenmäher bestellen #garage #wartung",
|
||
priority: "normal",
|
||
source: "manual",
|
||
},
|
||
{
|
||
content: "Urlaubsidee: Wanderhütte in Südtirol für Oktober buchen #urlaub #familie",
|
||
priority: "high",
|
||
source: "voice",
|
||
},
|
||
{
|
||
content: "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm",
|
||
priority: "urgent",
|
||
source: "webhook",
|
||
},
|
||
{
|
||
content: "Buch-Empfehlung aus dem Podcast: 'The Pragmatic Programmer' nochmal lesen #lesen",
|
||
priority: "low",
|
||
source: "manual",
|
||
},
|
||
{
|
||
content: "Steuerunterlagen für Q3 an den Steuerberater schicken #finanzen #büro",
|
||
priority: "high",
|
||
source: "manual",
|
||
},
|
||
}
|
||
|
||
for _, st := range starterThoughts {
|
||
_, _ = s.CreateThought(user.ID, st.content, st.priority, st.source, st.dueDate)
|
||
}
|
||
|
||
// Create one already kept Task with due date
|
||
keptTask, err := s.CreateThought(user.ID, "Gitea CI/CD Pipeline für MindSift Container testen #devops #docker", "high", "manual", now[:10])
|
||
if err == nil && keptTask != nil {
|
||
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10])
|
||
}
|
||
|
||
// 2. Create Shared Family Space & Shopping List
|
||
familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false)
|
||
if err == nil && familySpace != nil {
|
||
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
|
||
if err == nil && shopList != nil {
|
||
items := []struct {
|
||
content string
|
||
category string
|
||
quantity string
|
||
done bool
|
||
}{
|
||
{"Hafermilch (Barista Edition)", "Kühlregal", "3x", false},
|
||
{"San Marzano Dosentomaten", "Konserven", "2 Dosen", false},
|
||
{"Frischer Basilikum & Knoblauch", "Obst & Gemüse", "1 Bund", false},
|
||
{"Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", false},
|
||
{"Spülmaschinentabs", "Drogerie", "1 Packung", true},
|
||
}
|
||
for idx, it := range items {
|
||
created, err := s.CreateListItem(shopList.ID, it.content, it.category, it.quantity, idx)
|
||
if err == nil && it.done && created != nil {
|
||
_, _ = s.ToggleListItem(created.ID, true, user.ID, user.DisplayName)
|
||
}
|
||
}
|
||
}
|
||
|
||
vacationList, err := s.CreateList(familySpace.ID, "Packliste Südtirol-Trip", "🏔️", "vacation")
|
||
if err == nil && vacationList != nil {
|
||
_, _ = s.CreateListItem(vacationList.ID, "Wanderschuhe imprägnieren", "Ausrüstung", "", 0)
|
||
_, _ = s.CreateListItem(vacationList.ID, "Powerbank & Ladekabel", "Technik", "2x", 1)
|
||
_, _ = s.CreateListItem(vacationList.ID, "Vignette Österreich online kaufen", "Dokumente", "", 2)
|
||
}
|
||
}
|
||
|
||
// 3. Starter Workspace Document (Notion-Flair)
|
||
starterBlocks := []domain.DocumentBlock{
|
||
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran – ganz ohne Datenbank-Ballast."},
|
||
{ID: "blk_2", Type: "h2", Content: "Wie funktioniert der Workflow?"},
|
||
{ID: "blk_3", Type: "bullet", Content: "1. Capture First: Drücke jederzeit 'C' oder '/' und tippe oder diktiere deinen Gedanken in unter 3 Sekunden."},
|
||
{ID: "blk_4", Type: "bullet", Content: "2. Tinder-Style Triage: Wische Karten im Triage-Deck nach links (Verwerfen) oder rechts (Behalten / Aufgabe / Dokument)."},
|
||
{ID: "blk_5", Type: "bullet", Content: "3. Echtzeit-Listen: Teile deinen Space per Invite-Code mit Partner oder Familie. Abgehakte Einkäufe synchronisieren sich sofort via WebSocket."},
|
||
{ID: "blk_6", Type: "h2", Content: "Projekt-Checkliste: Homelab Upgrade"},
|
||
{ID: "blk_7", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true},
|
||
{ID: "blk_8", Type: "todo", Content: "Home Assistant Webhook für Rauchmelder & Luftfeuchtigkeit anbinden", Checked: false},
|
||
{ID: "blk_9", Type: "todo", Content: "PWA auf dem Smartphone-Homescreen installieren", Checked: false},
|
||
{ID: "blk_10", Type: "code", Content: "curl -X POST http://localhost:8080/api/v1/webhooks/ingest?token=DEIN_KEY \\\n -H 'Content-Type: application/json' \\\n -d '{\"title\": \"Waschmaschine im Keller ist fertig!\", \"priority\": \"high\", \"tags\": [\"smarthome\"]}'", Lang: "bash"},
|
||
}
|
||
|
||
_, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Homelab Notizen", "🚀", starterBlocks, []string{"homelab", "mindsift", "workflow"}, true)
|
||
return nil
|
||
}
|
||
|
||
// EnsureDefaultUser creates a default user if the database has 0 users so self-hosted users can jump right in
|
||
func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||
count, err := s.CountUsers()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if count > 0 {
|
||
return s.GetFirstUser()
|
||
}
|
||
|
||
hash, err := auth.HashPassword("mindsift")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
user, err := s.CreateUser("admin@mindsift.local", hash, "Sandro")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_ = s.SeedStarterContent(user)
|
||
return user, nil
|
||
}
|
||
|
||
// --- USERS ---
|
||
|
||
func (s *Store) CountUsers() (int, error) {
|
||
var count int
|
||
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
|
||
return count, err
|
||
}
|
||
|
||
func (s *Store) CreateUser(email, passwordHash, displayName string) (*domain.User, error) {
|
||
u := &domain.User{
|
||
ID: domain.NewID("usr"),
|
||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||
PasswordHash: passwordHash,
|
||
DisplayName: strings.TrimSpace(displayName),
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||
u.ID, u.Email, u.PasswordHash, u.DisplayName, u.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
|
||
u := &domain.User{}
|
||
err := s.db.QueryRow(
|
||
`SELECT id, email, password_hash, display_name, created_at FROM users WHERE email = ?`,
|
||
strings.ToLower(strings.TrimSpace(email)),
|
||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &u.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *Store) GetUserByID(id string) (*domain.User, error) {
|
||
u := &domain.User{}
|
||
err := s.db.QueryRow(
|
||
`SELECT id, email, password_hash, display_name, created_at FROM users WHERE id = ?`,
|
||
id,
|
||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &u.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *Store) GetFirstUser() (*domain.User, error) {
|
||
u := &domain.User{}
|
||
err := s.db.QueryRow(
|
||
`SELECT id, email, password_hash, display_name, created_at FROM users ORDER BY created_at ASC LIMIT 1`,
|
||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &u.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
// --- THOUGHTS & TRIAGE ---
|
||
|
||
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
|
||
content = strings.TrimSpace(content)
|
||
if content == "" {
|
||
return nil, errors.New("content cannot be empty")
|
||
}
|
||
if priority == "" {
|
||
priority = "normal"
|
||
}
|
||
if source == "" {
|
||
source = "manual"
|
||
}
|
||
|
||
tags := domain.ExtractTags(content)
|
||
tagsJSON, _ := json.Marshal(tags)
|
||
|
||
t := &domain.Thought{
|
||
ID: domain.NewID("th"),
|
||
UserID: userID,
|
||
Content: content,
|
||
Tags: tags,
|
||
Priority: priority,
|
||
Source: source,
|
||
Status: domain.StatusInbox,
|
||
DueDate: dueDate,
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
func scanThought(rows interface {
|
||
Scan(dest ...any) error
|
||
}) (*domain.Thought, error) {
|
||
t := &domain.Thought{}
|
||
var tagsStr, statusStr string
|
||
var isCompleted int
|
||
err := rows.Scan(
|
||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||
&t.DueDate, &t.CreatedAt, &t.TriagedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
t.Status = domain.ThoughtStatus(statusStr)
|
||
t.IsCompleted = isCompleted != 0
|
||
t.Tags = []string{}
|
||
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
||
return t, nil
|
||
}
|
||
|
||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||
row := s.db.QueryRow(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, created_at, triaged_at
|
||
FROM thoughts WHERE id = ? AND user_id = ?`,
|
||
thoughtID, userID,
|
||
)
|
||
return scanThought(row)
|
||
}
|
||
|
||
func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) {
|
||
if limit <= 0 || limit > 500 {
|
||
limit = 200
|
||
}
|
||
var rows *sql.Rows
|
||
var err error
|
||
|
||
if statusFilter != "" && statusFilter != "all" {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, created_at, triaged_at
|
||
FROM thoughts WHERE user_id = ? AND status = ?
|
||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`,
|
||
userID, statusFilter, limit,
|
||
)
|
||
} else {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, created_at, triaged_at
|
||
FROM thoughts WHERE user_id = ?
|
||
ORDER BY created_at DESC LIMIT ?`,
|
||
userID, limit,
|
||
)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
result := make([]domain.Thought, 0)
|
||
for rows.Next() {
|
||
t, err := scanThought(rows)
|
||
if err == nil {
|
||
result = append(result, *t)
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Store) GetTasks(userID string) ([]domain.Thought, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, created_at, triaged_at
|
||
FROM thoughts
|
||
WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
result := make([]domain.Thought, 0)
|
||
for rows.Next() {
|
||
t, err := scanThought(rows)
|
||
if err == nil {
|
||
result = append(result, *t)
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate string) (*domain.Thought, error) {
|
||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
now := domain.NowISO()
|
||
newStatus := domain.StatusKept
|
||
convertedType := thought.ConvertedType
|
||
convertedRefID := thought.ConvertedRefID
|
||
if dueDate == "" {
|
||
dueDate = thought.DueDate
|
||
}
|
||
|
||
switch action {
|
||
case "discard":
|
||
newStatus = domain.StatusDiscarded
|
||
case "restore":
|
||
newStatus = domain.StatusInbox
|
||
case "keep":
|
||
newStatus = domain.StatusKept
|
||
if convertTo == "task" {
|
||
newStatus = domain.StatusConverted
|
||
convertedType = "task"
|
||
} else if convertTo == "document" {
|
||
newStatus = domain.StatusConverted
|
||
convertedType = "document"
|
||
title := thought.Content
|
||
if len(title) > 72 {
|
||
title = title[:72] + "…"
|
||
}
|
||
blocks := []domain.DocumentBlock{
|
||
{ID: domain.NewID("blk"), Type: "callout", Content: "Ursprünglicher Gedanke aus deiner Inbox vom " + thought.CreatedAt[:10]},
|
||
{ID: domain.NewID("blk"), Type: "paragraph", Content: thought.Content},
|
||
{ID: domain.NewID("blk"), Type: "h2", Content: "Nächste Schritte"},
|
||
{ID: domain.NewID("blk"), Type: "todo", Content: "Ersten Meilenstein definieren", Checked: false},
|
||
}
|
||
doc, err := s.CreateDocument(userID, "", title, "✨", blocks, thought.Tags, false)
|
||
if err == nil && doc != nil {
|
||
convertedRefID = doc.ID
|
||
}
|
||
} else if convertTo == "list_item" && targetListID != "" {
|
||
newStatus = domain.StatusConverted
|
||
convertedType = "list_item"
|
||
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0)
|
||
if err == nil && item != nil {
|
||
convertedRefID = item.ID
|
||
}
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("invalid triage action: %s", action)
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`UPDATE thoughts
|
||
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, triaged_at = ?
|
||
WHERE id = ? AND user_id = ?`,
|
||
string(newStatus), convertedType, convertedRefID, dueDate, now, thoughtID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.GetThoughtByID(userID, thoughtID)
|
||
}
|
||
|
||
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string) (*domain.Thought, error) {
|
||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if content != nil {
|
||
thought.Content = strings.TrimSpace(*content)
|
||
thought.Tags = domain.ExtractTags(thought.Content)
|
||
}
|
||
if isCompleted != nil {
|
||
thought.IsCompleted = *isCompleted
|
||
}
|
||
if dueDate != nil {
|
||
thought.DueDate = *dueDate
|
||
}
|
||
if priority != nil && *priority != "" {
|
||
thought.Priority = *priority
|
||
}
|
||
|
||
tagsJSON, _ := json.Marshal(thought.Tags)
|
||
completedInt := 0
|
||
if thought.IsCompleted {
|
||
completedInt = 1
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ? WHERE id = ? AND user_id = ?`,
|
||
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thoughtID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return thought, nil
|
||
}
|
||
|
||
func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ? AND user_id = ?`, thoughtID, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
||
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return res.RowsAffected()
|
||
}
|
||
|
||
// --- DOCUMENTS (WORKSPACE) ---
|
||
|
||
func (s *Store) CreateDocument(userID, spaceID, title, icon string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) {
|
||
if strings.TrimSpace(title) == "" {
|
||
title = "Unbenannte Notiz"
|
||
}
|
||
if icon == "" {
|
||
icon = "📄"
|
||
}
|
||
if blocks == nil {
|
||
blocks = []domain.DocumentBlock{
|
||
{ID: domain.NewID("blk"), Type: "paragraph", Content: ""},
|
||
}
|
||
}
|
||
if tags == nil {
|
||
tags = []string{}
|
||
}
|
||
|
||
var plainBuilder strings.Builder
|
||
for _, b := range blocks {
|
||
if b.Content != "" {
|
||
plainBuilder.WriteString(b.Content)
|
||
plainBuilder.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
blocksJSON, _ := json.Marshal(blocks)
|
||
tagsJSON, _ := json.Marshal(tags)
|
||
now := domain.NowISO()
|
||
pinnedInt := 0
|
||
if isPinned {
|
||
pinnedInt = 1
|
||
}
|
||
|
||
doc := &domain.Document{
|
||
ID: domain.NewID("doc"),
|
||
UserID: userID,
|
||
SpaceID: spaceID,
|
||
Title: strings.TrimSpace(title),
|
||
Icon: icon,
|
||
Content: plainBuilder.String(),
|
||
Blocks: blocks,
|
||
Tags: tags,
|
||
IsPinned: isPinned,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, is_pinned, is_archived, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||
doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), pinnedInt, doc.CreatedAt, doc.UpdatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return doc, nil
|
||
}
|
||
|
||
func scanDocument(rows interface {
|
||
Scan(dest ...any) error
|
||
}) (*domain.Document, error) {
|
||
d := &domain.Document{}
|
||
var blocksStr, tagsStr string
|
||
var isPinned, isArchived int
|
||
err := rows.Scan(
|
||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||
&blocksStr, &tagsStr, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d.IsPinned = isPinned != 0
|
||
d.IsArchived = isArchived != 0
|
||
d.Blocks = []domain.DocumentBlock{}
|
||
d.Tags = []string{}
|
||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||
return d, nil
|
||
}
|
||
|
||
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, is_pinned, is_archived, created_at, updated_at
|
||
FROM documents WHERE user_id = ? AND is_archived = 0
|
||
ORDER BY is_pinned DESC, updated_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
docs := make([]domain.Document, 0)
|
||
for rows.Next() {
|
||
d, err := scanDocument(rows)
|
||
if err == nil {
|
||
docs = append(docs, *d)
|
||
}
|
||
}
|
||
return docs, nil
|
||
}
|
||
|
||
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
||
row := s.db.QueryRow(
|
||
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, is_pinned, is_archived, created_at, updated_at
|
||
FROM documents WHERE id = ? AND user_id = ?`,
|
||
docID, userID,
|
||
)
|
||
return scanDocument(row)
|
||
}
|
||
|
||
func (s *Store) UpdateDocument(userID, docID string, title, icon *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) {
|
||
doc, err := s.GetDocumentByID(userID, docID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if title != nil {
|
||
doc.Title = strings.TrimSpace(*title)
|
||
}
|
||
if icon != nil && *icon != "" {
|
||
doc.Icon = *icon
|
||
}
|
||
if blocks != nil {
|
||
doc.Blocks = *blocks
|
||
var plainBuilder strings.Builder
|
||
for _, b := range doc.Blocks {
|
||
if b.Content != "" {
|
||
plainBuilder.WriteString(b.Content)
|
||
plainBuilder.WriteString("\n")
|
||
}
|
||
}
|
||
doc.Content = plainBuilder.String()
|
||
}
|
||
if tags != nil {
|
||
doc.Tags = *tags
|
||
}
|
||
if isPinned != nil {
|
||
doc.IsPinned = *isPinned
|
||
}
|
||
|
||
doc.UpdatedAt = domain.NowISO()
|
||
blocksJSON, _ := json.Marshal(doc.Blocks)
|
||
tagsJSON, _ := json.Marshal(doc.Tags)
|
||
pinnedInt := 0
|
||
if doc.IsPinned {
|
||
pinnedInt = 1
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, is_pinned = ?, updated_at = ?
|
||
WHERE id = ? AND user_id = ?`,
|
||
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), pinnedInt, doc.UpdatedAt, docID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return doc, nil
|
||
}
|
||
|
||
func (s *Store) DeleteDocument(userID, docID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
||
return err
|
||
}
|
||
|
||
// --- SPACES & REALTIME LISTS ---
|
||
|
||
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
|
||
if strings.TrimSpace(name) == "" {
|
||
return nil, errors.New("space name required")
|
||
}
|
||
if icon == "" {
|
||
icon = "🏠"
|
||
}
|
||
now := domain.NowISO()
|
||
inviteCode := strings.ToUpper(domain.NewID("inv")[4:12])
|
||
personalInt := 0
|
||
if isPersonal {
|
||
personalInt = 1
|
||
}
|
||
|
||
sp := &domain.Space{
|
||
ID: domain.NewID("spc"),
|
||
Name: strings.TrimSpace(name),
|
||
Icon: icon,
|
||
Description: strings.TrimSpace(description),
|
||
InviteCode: inviteCode,
|
||
IsPersonal: isPersonal,
|
||
OwnerID: ownerID,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, _ = s.db.Exec(
|
||
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`,
|
||
sp.ID, ownerID, now,
|
||
)
|
||
return s.GetSpaceByID(sp.ID)
|
||
}
|
||
|
||
func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) {
|
||
inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode))
|
||
var spaceID string
|
||
err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID)
|
||
if err != nil {
|
||
return nil, errors.New("Ungültiger Einladungscode")
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`,
|
||
spaceID, userID, domain.NowISO(),
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetSpaceByID(spaceID)
|
||
}
|
||
|
||
func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) {
|
||
sp := &domain.Space{}
|
||
var personalInt int
|
||
err := s.db.QueryRow(
|
||
`SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`,
|
||
spaceID,
|
||
).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sp.IsPersonal = personalInt != 0
|
||
sp.Members, _ = s.GetSpaceMembers(spaceID)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount)
|
||
return sp, nil
|
||
}
|
||
|
||
func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
|
||
FROM space_members sm
|
||
JOIN users u ON u.id = sm.user_id
|
||
WHERE sm.space_id = ?
|
||
ORDER BY sm.joined_at ASC`,
|
||
spaceID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
members := make([]domain.SpaceMember, 0)
|
||
for rows.Next() {
|
||
var m domain.SpaceMember
|
||
if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
|
||
members = append(members, m)
|
||
}
|
||
}
|
||
return members, nil
|
||
}
|
||
|
||
func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
|
||
FROM spaces s
|
||
JOIN space_members sm ON sm.space_id = s.id
|
||
WHERE sm.user_id = ?
|
||
ORDER BY s.created_at ASC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
spaces := make([]domain.Space, 0)
|
||
for rows.Next() {
|
||
var sp domain.Space
|
||
var personalInt int
|
||
if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil {
|
||
sp.IsPersonal = personalInt != 0
|
||
sp.Members, _ = s.GetSpaceMembers(sp.ID)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, sp.ID).Scan(&sp.ListsCount)
|
||
spaces = append(spaces, sp)
|
||
}
|
||
}
|
||
return spaces, nil
|
||
}
|
||
|
||
func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.SharedList, error) {
|
||
if strings.TrimSpace(title) == "" {
|
||
return nil, errors.New("list title required")
|
||
}
|
||
if icon == "" {
|
||
icon = "🛒"
|
||
}
|
||
if listType == "" {
|
||
listType = "shopping"
|
||
}
|
||
now := domain.NowISO()
|
||
lst := &domain.SharedList{
|
||
ID: domain.NewID("lst"),
|
||
SpaceID: spaceID,
|
||
Title: strings.TrimSpace(title),
|
||
Icon: icon,
|
||
ListType: listType,
|
||
Items: []domain.ListItem{},
|
||
UpdatedAt: now,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return lst, nil
|
||
}
|
||
|
||
func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at
|
||
FROM lists WHERE space_id = ? ORDER BY created_at ASC`,
|
||
spaceID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
lists := make([]domain.SharedList, 0)
|
||
for rows.Next() {
|
||
var l domain.SharedList
|
||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||
l.Items, _ = s.GetListItems(l.ID)
|
||
lists = append(lists, l)
|
||
}
|
||
}
|
||
return lists, nil
|
||
}
|
||
|
||
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at
|
||
FROM lists l
|
||
JOIN space_members sm ON sm.space_id = l.space_id
|
||
WHERE sm.user_id = ?
|
||
ORDER BY l.updated_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
lists := make([]domain.SharedList, 0)
|
||
for rows.Next() {
|
||
var l domain.SharedList
|
||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||
l.Items, _ = s.GetListItems(l.ID)
|
||
lists = append(lists, l)
|
||
}
|
||
}
|
||
return lists, nil
|
||
}
|
||
|
||
func (s *Store) GetListByID(listID string) (*domain.SharedList, error) {
|
||
var l domain.SharedList
|
||
err := s.db.QueryRow(
|
||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`,
|
||
listID,
|
||
).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
l.Items, _ = s.GetListItems(l.ID)
|
||
return &l, nil
|
||
}
|
||
|
||
func (s *Store) DeleteList(listID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||
FROM list_items WHERE list_id = ?
|
||
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||
listID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]domain.ListItem, 0)
|
||
for rows.Next() {
|
||
var it domain.ListItem
|
||
var doneInt int
|
||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
|
||
it.IsCompleted = doneInt != 0
|
||
items = append(items, it)
|
||
}
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int) (*domain.ListItem, error) {
|
||
content = strings.TrimSpace(content)
|
||
if content == "" {
|
||
return nil, errors.New("item content cannot be empty")
|
||
}
|
||
now := domain.NowISO()
|
||
it := &domain.ListItem{
|
||
ID: domain.NewID("itm"),
|
||
ListID: listID,
|
||
Content: content,
|
||
Category: strings.TrimSpace(category),
|
||
Quantity: strings.TrimSpace(quantity),
|
||
IsCompleted: false,
|
||
SortOrder: sortOrder,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at)
|
||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
|
||
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, listID)
|
||
return it, nil
|
||
}
|
||
|
||
func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName string) (*domain.ListItem, error) {
|
||
now := domain.NowISO()
|
||
doneInt := 0
|
||
completedAt := ""
|
||
completedBy := ""
|
||
completedByName := ""
|
||
if isCompleted {
|
||
doneInt = 1
|
||
completedAt = now
|
||
completedBy = userID
|
||
completedByName = userName
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`UPDATE list_items SET is_completed = ?, completed_by = ?, completed_by_name = ?, completed_at = ? WHERE id = ?`,
|
||
doneInt, completedBy, completedByName, completedAt, itemID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var it domain.ListItem
|
||
var dInt int
|
||
err = s.db.QueryRow(
|
||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||
FROM list_items WHERE id = ?`,
|
||
itemID,
|
||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
it.IsCompleted = dInt != 0
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||
return &it, nil
|
||
}
|
||
|
||
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||
var listID string
|
||
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
||
_, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, itemID)
|
||
if listID != "" {
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||
}
|
||
return listID, err
|
||
}
|
||
|
||
func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
|
||
res, err := s.db.Exec(`DELETE FROM list_items WHERE list_id = ? AND is_completed = 1`, listID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||
return res.RowsAffected()
|
||
}
|
||
|
||
// --- API KEYS & WEBHOOKS ---
|
||
|
||
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
||
if strings.TrimSpace(name) == "" {
|
||
name = "Smarthome / Webhook Key"
|
||
}
|
||
rawKey, prefix, hash := auth.GenerateAPIKey()
|
||
k := &domain.APIKey{
|
||
ID: domain.NewID("key"),
|
||
UserID: userID,
|
||
Name: strings.TrimSpace(name),
|
||
KeyPrefix: prefix,
|
||
KeyHash: hash,
|
||
RawKey: rawKey,
|
||
Scopes: "ingest,read",
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return k, nil
|
||
}
|
||
|
||
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
|
||
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
keys := make([]domain.APIKey, 0)
|
||
for rows.Next() {
|
||
var k domain.APIKey
|
||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
|
||
keys = append(keys, k)
|
||
}
|
||
}
|
||
return keys, nil
|
||
}
|
||
|
||
func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||
var keyID, userID string
|
||
err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID)
|
||
if err != nil {
|
||
return nil, errors.New("invalid API key")
|
||
}
|
||
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
||
return s.GetUserByID(userID)
|
||
}
|
||
|
||
// --- STATS, SEARCH & EXPORT ---
|
||
|
||
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
|
||
st := &domain.DashboardStats{}
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 0`, userID).Scan(&st.TasksOpenCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 1`, userID).Scan(&st.TasksDoneCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists l JOIN space_members sm ON sm.space_id = l.space_id WHERE sm.user_id = ?`, userID).Scan(&st.ListsCount)
|
||
return st, nil
|
||
}
|
||
|
||
func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
|
||
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||
|
||
tRows, err := s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, created_at, triaged_at
|
||
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
|
||
userID, q,
|
||
)
|
||
thoughts := make([]domain.Thought, 0)
|
||
if err == nil {
|
||
defer tRows.Close()
|
||
for tRows.Next() {
|
||
if t, err := scanThought(tRows); err == nil {
|
||
thoughts = append(thoughts, *t)
|
||
}
|
||
}
|
||
}
|
||
|
||
dRows, err := s.db.Query(
|
||
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, is_pinned, is_archived, created_at, updated_at
|
||
FROM documents WHERE user_id = ? AND (LOWER(title) LIKE ? OR LOWER(content) LIKE ?) ORDER BY updated_at DESC LIMIT 20`,
|
||
userID, q, q,
|
||
)
|
||
docs := make([]domain.Document, 0)
|
||
if err == nil {
|
||
defer dRows.Close()
|
||
for dRows.Next() {
|
||
if d, err := scanDocument(dRows); err == nil {
|
||
docs = append(docs, *d)
|
||
}
|
||
}
|
||
}
|
||
|
||
return thoughts, docs, nil
|
||
}
|
||
|
||
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||
user, err := s.GetUserByID(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
thoughts, _ := s.GetThoughts(userID, "all", 5000)
|
||
docs, _ := s.GetDocuments(userID)
|
||
spaces, _ := s.GetSpacesForUser(userID)
|
||
lists, _ := s.GetAllListsForUser(userID)
|
||
|
||
return &domain.FullExport{
|
||
ExportedAt: domain.NowISO(),
|
||
Version: "1.0.0",
|
||
User: *user,
|
||
Thoughts: thoughts,
|
||
Documents: docs,
|
||
Spaces: spaces,
|
||
Lists: lists,
|
||
}, nil
|
||
}
|