MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s
3325 lines
100 KiB
Go
3325 lines
100 KiB
Go
package storage
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/sandrodach/mindsift/internal/auth"
|
||
"github.com/sandrodach/mindsift/internal/domain"
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
type Store struct {
|
||
db *sql.DB
|
||
docDir string
|
||
initMu sync.Mutex
|
||
}
|
||
|
||
func NewStore(dbPath string) (*Store, error) {
|
||
var docDir string
|
||
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
|
||
dir := filepath.Dir(dbPath)
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err)
|
||
}
|
||
docDir = filepath.Join(dir, "workspace")
|
||
_ = os.MkdirAll(docDir, 0755)
|
||
}
|
||
|
||
dsn := dbPath
|
||
if !strings.Contains(dsn, "?") {
|
||
dsn += "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
|
||
}
|
||
|
||
db, err := sql.Open("sqlite", dsn)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
|
||
}
|
||
|
||
db.SetMaxOpenConns(8)
|
||
db.SetMaxIdleConns(4)
|
||
|
||
store := &Store{db: db, docDir: docDir}
|
||
if err := store.migrate(); err != nil {
|
||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||
}
|
||
|
||
return store, nil
|
||
}
|
||
|
||
func (s *Store) Close() error {
|
||
return s.db.Close()
|
||
}
|
||
|
||
func (s *Store) migrate() error {
|
||
schema := `
|
||
PRAGMA journal_mode = WAL;
|
||
PRAGMA synchronous = NORMAL;
|
||
PRAGMA foreign_keys = ON;
|
||
PRAGMA busy_timeout = 5000;
|
||
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY,
|
||
email TEXT UNIQUE NOT NULL,
|
||
username TEXT NOT NULL DEFAULT '',
|
||
password_hash TEXT NOT NULL,
|
||
display_name TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'member',
|
||
is_active INTEGER NOT NULL DEFAULT 1,
|
||
alarm_channels TEXT NOT NULL DEFAULT 'discord',
|
||
alarm_email TEXT NOT NULL DEFAULT '',
|
||
discord_webhook_url TEXT NOT NULL DEFAULT '',
|
||
custom_webhook_url TEXT NOT NULL DEFAULT '',
|
||
notify_before_minutes INTEGER NOT NULL DEFAULT 30,
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS user_invites (
|
||
id TEXT PRIMARY KEY,
|
||
code TEXT UNIQUE NOT NULL,
|
||
email TEXT NOT NULL DEFAULT '',
|
||
role TEXT NOT NULL DEFAULT 'member',
|
||
team_id TEXT NOT NULL DEFAULT '',
|
||
created_by TEXT NOT NULL,
|
||
expires_at TEXT NOT NULL,
|
||
used_by TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS thoughts (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
tags TEXT NOT NULL DEFAULT '[]',
|
||
priority TEXT NOT NULL DEFAULT 'normal',
|
||
source TEXT NOT NULL DEFAULT 'manual',
|
||
status TEXT NOT NULL DEFAULT 'inbox',
|
||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||
converted_type TEXT DEFAULT '',
|
||
converted_ref_id TEXT DEFAULT '',
|
||
due_date TEXT DEFAULT '',
|
||
task_list_id TEXT NOT NULL DEFAULT '',
|
||
kanban_status TEXT NOT NULL DEFAULT 'todo',
|
||
notes TEXT NOT NULL DEFAULT '',
|
||
alarm_sent INTEGER NOT NULL DEFAULT 0,
|
||
subtasks TEXT NOT NULL DEFAULT '[]',
|
||
created_at TEXT NOT NULL,
|
||
triaged_at TEXT DEFAULT '',
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
|
||
CREATE INDEX IF NOT EXISTS idx_thoughts_task_list ON thoughts(task_list_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS task_lists (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
space_id TEXT DEFAULT '',
|
||
team_id TEXT DEFAULT '',
|
||
title TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '📋',
|
||
color TEXT NOT NULL DEFAULT '#2563eb',
|
||
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 doc_shares (
|
||
id TEXT PRIMARY KEY,
|
||
doc_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
permission TEXT NOT NULL CHECK(permission IN ('read', 'write')),
|
||
created_at TEXT NOT NULL,
|
||
created_by TEXT NOT NULL,
|
||
UNIQUE(doc_id, user_id),
|
||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);
|
||
CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS doc_pins (
|
||
user_id TEXT NOT NULL,
|
||
doc_id TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
PRIMARY KEY(user_id, doc_id),
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_doc_pins_user ON doc_pins(user_id);
|
||
|
||
INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at)
|
||
SELECT user_id, id, updated_at FROM documents WHERE is_pinned = 1;
|
||
|
||
CREATE TABLE IF NOT EXISTS doc_block_tags (
|
||
id TEXT PRIMARY KEY,
|
||
tag TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
doc_id TEXT NOT NULL,
|
||
block_id TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
UNIQUE(doc_id, block_id, tag),
|
||
FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_tag ON doc_block_tags(tag);
|
||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
|
||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS lists (
|
||
id TEXT PRIMARY KEY,
|
||
space_id TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '🛒',
|
||
list_type TEXT NOT NULL DEFAULT 'shopping',
|
||
updated_at TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS list_items (
|
||
id TEXT PRIMARY KEY,
|
||
list_id TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
category TEXT NOT NULL DEFAULT '',
|
||
quantity TEXT NOT NULL DEFAULT '',
|
||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||
completed_by TEXT NOT NULL DEFAULT '',
|
||
completed_by_name TEXT NOT NULL DEFAULT '',
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
completed_at TEXT NOT NULL DEFAULT '',
|
||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
|
||
|
||
CREATE TABLE IF NOT EXISTS api_keys (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
key_prefix TEXT NOT NULL,
|
||
key_hash TEXT UNIQUE NOT NULL,
|
||
scopes TEXT NOT NULL DEFAULT 'ingest,read',
|
||
last_used_at TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS system_settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
`
|
||
if _, err := s.db.Exec(schema); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Idempotent column migrations for existing databases
|
||
columnMigrations := []string{
|
||
"ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';",
|
||
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
|
||
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
|
||
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
||
"ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';",
|
||
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
|
||
"ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';",
|
||
"ALTER TABLE users ADD COLUMN custom_webhook_url TEXT NOT NULL DEFAULT '';",
|
||
"ALTER TABLE users ADD COLUMN notify_before_minutes INTEGER NOT NULL DEFAULT 30;",
|
||
|
||
"ALTER TABLE thoughts ADD COLUMN task_list_id TEXT NOT NULL DEFAULT '';",
|
||
"ALTER TABLE thoughts ADD COLUMN kanban_status TEXT NOT NULL DEFAULT 'todo';",
|
||
"ALTER TABLE thoughts ADD COLUMN notes TEXT NOT NULL DEFAULT '';",
|
||
"ALTER TABLE thoughts ADD COLUMN alarm_sent INTEGER NOT NULL DEFAULT 0;",
|
||
"ALTER TABLE thoughts ADD COLUMN subtasks TEXT NOT NULL DEFAULT '[]';",
|
||
|
||
"ALTER TABLE documents ADD COLUMN folder_path TEXT NOT NULL DEFAULT '';",
|
||
|
||
"CREATE TABLE IF NOT EXISTS doc_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, user_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, user_id));",
|
||
"CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);",
|
||
"CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);",
|
||
|
||
"ALTER TABLE task_lists ADD COLUMN team_id TEXT NOT NULL DEFAULT '';",
|
||
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
|
||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
|
||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);",
|
||
}
|
||
for _, q := range columnMigrations {
|
||
_, _ = s.db.Exec(q)
|
||
}
|
||
|
||
// Backfill username for existing accounts where username is empty
|
||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||
|
||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||
('sharing_mode', 'teams_only'),
|
||
('allow_registration', 'false'),
|
||
('instance_name', 'MindSift'),
|
||
('smtp_host', ''),
|
||
('smtp_port', '587'),
|
||
('smtp_user', ''),
|
||
('smtp_pass', ''),
|
||
('smtp_from', ''),
|
||
('smtp_tls', 'true')`)
|
||
|
||
// Reindex block tags if table is newly added or empty
|
||
var tagCount int
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount)
|
||
if tagCount == 0 {
|
||
_ = s.reindexAllDocBlockTags()
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// SeedStarterContent populates realistic initial content for a newly created user if requested
|
||
func (s *Store) SeedStarterContent(user *domain.User) error {
|
||
now := domain.NowISO()
|
||
|
||
starterThoughts := []struct {
|
||
content string
|
||
priority string
|
||
source string
|
||
dueDate string
|
||
}{
|
||
{
|
||
content: "Zündkerzen und Luftfilter für den Rasenmäher bestellen #garage #wartung",
|
||
priority: "normal",
|
||
source: "manual",
|
||
},
|
||
{
|
||
content: "Urlaubsidee: Wanderhütte in Südtirol für Oktober buchen #urlaub #familie",
|
||
priority: "high",
|
||
source: "voice",
|
||
},
|
||
{
|
||
content: "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm",
|
||
priority: "urgent",
|
||
source: "webhook",
|
||
},
|
||
}
|
||
|
||
for _, st := range starterThoughts {
|
||
_, _ = s.CreateThought(user.ID, st.content, st.priority, st.source, st.dueDate)
|
||
}
|
||
|
||
keptTask, err := s.CreateThought(user.ID, "Gitea CI/CD Pipeline für MindSift Container testen #devops #docker", "high", "manual", now[:10])
|
||
if err == nil && keptTask != nil {
|
||
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "")
|
||
}
|
||
|
||
familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false)
|
||
if err == nil && familySpace != nil {
|
||
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
|
||
if err == nil && shopList != nil {
|
||
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
|
||
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
|
||
}
|
||
}
|
||
|
||
starterBlocks := []domain.DocumentBlock{
|
||
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."},
|
||
{ID: "blk_2", Type: "h2", Content: "Projekt-Checkliste"},
|
||
{ID: "blk_3", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true},
|
||
}
|
||
|
||
_, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Notizen", "🚀", "", starterBlocks, []string{"mindsift", "workflow"}, true)
|
||
return nil
|
||
}
|
||
|
||
// EnsureDefaultUser is retained strictly for unit test convenience
|
||
func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||
s.initMu.Lock()
|
||
defer s.initMu.Unlock()
|
||
|
||
count, err := s.CountUsers()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if count > 0 {
|
||
return s.GetFirstUser()
|
||
}
|
||
|
||
hash, err := auth.HashPassword("mindsift")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
user, err := s.CreateUser("test@mindsift.local", hash, "Admin", "admin")
|
||
if err != nil {
|
||
return s.GetFirstUser()
|
||
}
|
||
return user, nil
|
||
}
|
||
|
||
// --- USERS ---
|
||
|
||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at`
|
||
|
||
func parseAlarmChannels(raw string) []string {
|
||
parts := strings.Split(raw, ",")
|
||
res := make([]string, 0, len(parts))
|
||
seen := make(map[string]bool)
|
||
for _, p := range parts {
|
||
p = strings.ToLower(strings.TrimSpace(p))
|
||
if (p == "email" || p == "discord" || p == "webhook") && !seen[p] {
|
||
seen[p] = true
|
||
res = append(res, p)
|
||
}
|
||
}
|
||
return res
|
||
}
|
||
|
||
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||
u := &domain.User{}
|
||
var isActiveInt int
|
||
var channelsRaw string
|
||
err := row.Scan(
|
||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
|
||
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
|
||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
u.IsActive = isActiveInt != 0
|
||
if u.Role == "" {
|
||
u.Role = "member"
|
||
}
|
||
u.AlarmChannels = parseAlarmChannels(channelsRaw)
|
||
if u.AlarmEmail == "" {
|
||
u.AlarmEmail = u.Email
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *Store) CountUsers() (int, error) {
|
||
var count int
|
||
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
|
||
return count, err
|
||
}
|
||
|
||
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
|
||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||
username := strings.Split(cleanEmail, "@")[0]
|
||
return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role)
|
||
}
|
||
|
||
func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) {
|
||
if role != "admin" && role != "team_admin" && role != "member" {
|
||
role = "member"
|
||
}
|
||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||
cleanUsername := strings.ToLower(strings.TrimSpace(username))
|
||
if cleanUsername == "" {
|
||
cleanUsername = strings.Split(cleanEmail, "@")[0]
|
||
}
|
||
u := &domain.User{
|
||
ID: domain.NewID("usr"),
|
||
Email: cleanEmail,
|
||
Username: cleanUsername,
|
||
PasswordHash: passwordHash,
|
||
DisplayName: strings.TrimSpace(displayName),
|
||
Role: role,
|
||
IsActive: true,
|
||
AlarmChannels: []string{},
|
||
AlarmEmail: cleanEmail,
|
||
DiscordWebhookURL: "",
|
||
CustomWebhookURL: "",
|
||
NotifyBeforeMinutes: 30,
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`,
|
||
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
|
||
return s.GetUserByLogin(email)
|
||
}
|
||
|
||
func (s *Store) GetUserByLogin(login string) (*domain.User, error) {
|
||
cleanLogin := strings.ToLower(strings.TrimSpace(login))
|
||
if cleanLogin == "" {
|
||
return nil, errors.New("benutzername oder e-mail darf nicht leer sein")
|
||
}
|
||
row := s.db.QueryRow(
|
||
`SELECT `+userSelectCols+` FROM users
|
||
WHERE LOWER(email) = ?
|
||
OR (username != '' AND LOWER(username) = ?)
|
||
OR LOWER(display_name) = ?
|
||
OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?)
|
||
LIMIT 1`,
|
||
cleanLogin, cleanLogin, cleanLogin, cleanLogin,
|
||
)
|
||
return scanUser(row)
|
||
}
|
||
|
||
// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation
|
||
func (s *Store) EnsureDemoAccounts() error {
|
||
s.initMu.Lock()
|
||
defer s.initMu.Unlock()
|
||
|
||
adminHash, err := auth.HashPassword("admin")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
userHash, err := auth.HashPassword("user")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 1. Admin user (admin / admin)
|
||
adminUser, err := s.GetUserByLogin("admin")
|
||
if err != nil || adminUser == nil {
|
||
adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create demo admin: %w", err)
|
||
}
|
||
_ = s.SeedStarterContent(adminUser)
|
||
} else {
|
||
_ = s.UpdateUserPassword(adminUser.ID, adminHash)
|
||
_, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID)
|
||
}
|
||
|
||
// 2. Regular user (user / user)
|
||
normalUser, err := s.GetUserByLogin("user")
|
||
if err != nil || normalUser == nil {
|
||
_, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create demo user: %w", err)
|
||
}
|
||
} else {
|
||
_ = s.UpdateUserPassword(normalUser.ID, userHash)
|
||
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) GetUserByID(id string) (*domain.User, error) {
|
||
row := s.db.QueryRow(
|
||
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
|
||
id,
|
||
)
|
||
return scanUser(row)
|
||
}
|
||
|
||
func (s *Store) GetFirstUser() (*domain.User, error) {
|
||
row := s.db.QueryRow(
|
||
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC LIMIT 1`,
|
||
)
|
||
return scanUser(row)
|
||
}
|
||
|
||
func (s *Store) GetAllUsers() ([]domain.User, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
users := make([]domain.User, 0)
|
||
for rows.Next() {
|
||
if u, err := scanUser(rows); err == nil {
|
||
users = append(users, *u)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
return users, nil
|
||
}
|
||
|
||
func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
|
||
viewer, err := s.GetUserByID(viewerID)
|
||
if err != nil {
|
||
return []domain.User{}, nil
|
||
}
|
||
sys, _ := s.GetSystemSettings()
|
||
mode := "teams_only"
|
||
if sys != nil && sys.SharingMode != "" {
|
||
mode = sys.SharingMode
|
||
}
|
||
|
||
if viewer.Role == "admin" || viewer.Role == "team_admin" || mode == "open" {
|
||
return s.GetAllUsers()
|
||
}
|
||
if mode == "strict_private" {
|
||
return []domain.User{*viewer}, nil
|
||
}
|
||
|
||
// teams_only: if no teams exist yet, allow discovering users on this instance
|
||
var teamCount int
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teamCount)
|
||
if teamCount == 0 {
|
||
return s.GetAllUsers()
|
||
}
|
||
|
||
// users sharing at least one team, organization or space with viewer
|
||
rows, err := s.db.Query(
|
||
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
|
||
SELECT tm2.user_id FROM team_members tm1
|
||
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
|
||
WHERE tm1.user_id = ?
|
||
UNION
|
||
SELECT sm2.user_id FROM space_members sm1
|
||
JOIN space_members sm2 ON sm1.space_id = sm2.space_id
|
||
WHERE sm1.user_id = ?
|
||
)`,
|
||
viewerID, viewerID, viewerID,
|
||
)
|
||
if err != nil {
|
||
return []domain.User{*viewer}, nil
|
||
}
|
||
users := make([]domain.User, 0)
|
||
for rows.Next() {
|
||
if u, err := scanUser(rows); err == nil {
|
||
users = append(users, *u)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
return users, nil
|
||
}
|
||
|
||
func (s *Store) UpdateUser(
|
||
userID string,
|
||
displayName, email, role *string,
|
||
isActive *bool,
|
||
discordWebhookURL *string,
|
||
notifyBeforeMinutes *int,
|
||
) (*domain.User, error) {
|
||
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes)
|
||
}
|
||
|
||
func (s *Store) UpdateUserFull(
|
||
userID string,
|
||
displayName, email, role *string,
|
||
isActive *bool,
|
||
alarmChannels *[]string,
|
||
alarmEmail, discordWebhookURL, customWebhookURL *string,
|
||
notifyBeforeMinutes *int,
|
||
) (*domain.User, error) {
|
||
u, err := s.GetUserByID(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if displayName != nil {
|
||
u.DisplayName = strings.TrimSpace(*displayName)
|
||
}
|
||
if email != nil {
|
||
u.Email = strings.ToLower(strings.TrimSpace(*email))
|
||
}
|
||
if role != nil && (*role == "admin" || *role == "team_admin" || *role == "member") {
|
||
u.Role = *role
|
||
}
|
||
if isActive != nil {
|
||
u.IsActive = *isActive
|
||
}
|
||
if alarmChannels != nil {
|
||
u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ","))
|
||
}
|
||
if alarmEmail != nil {
|
||
u.AlarmEmail = strings.TrimSpace(*alarmEmail)
|
||
}
|
||
if discordWebhookURL != nil {
|
||
u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL)
|
||
}
|
||
if customWebhookURL != nil {
|
||
u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL)
|
||
}
|
||
if notifyBeforeMinutes != nil {
|
||
u.NotifyBeforeMinutes = *notifyBeforeMinutes
|
||
}
|
||
activeInt := 0
|
||
if u.IsActive {
|
||
activeInt = 1
|
||
}
|
||
channelsStr := strings.Join(u.AlarmChannels, ",")
|
||
_, err = s.db.Exec(
|
||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ? WHERE id = ?`,
|
||
u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
||
_, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) DeleteUser(userID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||
return err
|
||
}
|
||
|
||
// --- THOUGHTS & TRIAGE ---
|
||
|
||
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
|
||
return s.CreateThoughtWithDetails(userID, content, priority, source, dueDate, "", "todo", "")
|
||
}
|
||
|
||
func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) {
|
||
content = strings.TrimSpace(content)
|
||
if content == "" {
|
||
return nil, errors.New("content cannot be empty")
|
||
}
|
||
if priority == "" {
|
||
priority = "normal"
|
||
}
|
||
if source == "" {
|
||
source = "manual"
|
||
}
|
||
if kanbanStatus == "" {
|
||
kanbanStatus = "todo"
|
||
}
|
||
|
||
tags := domain.ExtractTags(content)
|
||
tagsJSON, _ := json.Marshal(tags)
|
||
|
||
t := &domain.Thought{
|
||
ID: domain.NewID("th"),
|
||
UserID: userID,
|
||
Content: content,
|
||
Tags: tags,
|
||
Priority: priority,
|
||
Source: source,
|
||
Status: domain.StatusInbox,
|
||
DueDate: dueDate,
|
||
TaskListID: taskListID,
|
||
KanbanStatus: kanbanStatus,
|
||
Notes: notes,
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
|
||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
t.Subtasks = []domain.SubTask{}
|
||
return t, nil
|
||
}
|
||
|
||
func scanThought(rows interface {
|
||
Scan(dest ...any) error
|
||
}) (*domain.Thought, error) {
|
||
t := &domain.Thought{}
|
||
var tagsStr, subtasksStr, statusStr string
|
||
var isCompleted, alarmSent int
|
||
err := rows.Scan(
|
||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||
&t.CreatedAt, &t.TriagedAt, &subtasksStr,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
t.Status = domain.ThoughtStatus(statusStr)
|
||
t.IsCompleted = isCompleted != 0
|
||
t.AlarmSent = alarmSent != 0
|
||
if t.KanbanStatus == "" {
|
||
if t.IsCompleted {
|
||
t.KanbanStatus = "done"
|
||
} else {
|
||
t.KanbanStatus = "todo"
|
||
}
|
||
}
|
||
t.Tags = []string{}
|
||
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
|
||
t.Subtasks = []domain.SubTask{}
|
||
if subtasksStr != "" && subtasksStr != "null" {
|
||
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
func (s *Store) accessibleTaskListIDsQuery() string {
|
||
return `SELECT id FROM task_lists
|
||
WHERE user_id = ?
|
||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||
OR id IN (
|
||
SELECT task_list_id FROM task_list_shares
|
||
WHERE (target_type = 'user' AND target_id = ?)
|
||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||
)
|
||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))`
|
||
}
|
||
|
||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||
row := s.db.QueryRow(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||
thoughtID, userID, userID, userID, userID, userID, userID,
|
||
)
|
||
return scanThought(row)
|
||
}
|
||
|
||
func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) {
|
||
if limit <= 0 || limit > 500 {
|
||
limit = 200
|
||
}
|
||
var rows *sql.Rows
|
||
var err error
|
||
|
||
if statusFilter != "" && statusFilter != "all" {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||
FROM thoughts WHERE user_id = ? AND status = ?
|
||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`,
|
||
userID, statusFilter, limit,
|
||
)
|
||
} else {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||
FROM thoughts WHERE user_id = ?
|
||
ORDER BY created_at DESC LIMIT ?`,
|
||
userID, limit,
|
||
)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
result := make([]domain.Thought, 0)
|
||
for rows.Next() {
|
||
t, err := scanThought(rows)
|
||
if err == nil {
|
||
result = append(result, *t)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, error) {
|
||
var rows *sql.Rows
|
||
var err error
|
||
if taskListID != "" {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||
FROM thoughts
|
||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||
AND task_list_id = ?
|
||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||
userID, userID, userID, userID, userID, userID, taskListID,
|
||
)
|
||
} else {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||
FROM thoughts
|
||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||
userID, userID, userID, userID, userID, userID,
|
||
)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
result := make([]domain.Thought, 0)
|
||
for rows.Next() {
|
||
t, err := scanThought(rows)
|
||
if err == nil {
|
||
result = append(result, *t)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
return result, nil
|
||
}
|
||
|
||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
now := domain.NowISO()
|
||
newStatus := domain.StatusKept
|
||
convertedType := thought.ConvertedType
|
||
convertedRefID := thought.ConvertedRefID
|
||
if dueDate == "" {
|
||
dueDate = thought.DueDate
|
||
}
|
||
if taskListID == "" {
|
||
taskListID = thought.TaskListID
|
||
}
|
||
|
||
switch action {
|
||
case "discard":
|
||
newStatus = domain.StatusDiscarded
|
||
case "restore":
|
||
newStatus = domain.StatusInbox
|
||
case "keep":
|
||
newStatus = domain.StatusKept
|
||
if convertTo == "task" {
|
||
newStatus = domain.StatusConverted
|
||
convertedType = "task"
|
||
} else if convertTo == "document" {
|
||
newStatus = domain.StatusConverted
|
||
convertedType = "document"
|
||
title := thought.Content
|
||
if len(title) > 72 {
|
||
title = title[:72] + "…"
|
||
}
|
||
blocks := make([]domain.DocumentBlock, 0)
|
||
formattedDate := domain.FormatGermanDateTime(thought.CreatedAt)
|
||
calloutText := "Ursprünglicher Gedanke aus deiner Inbox"
|
||
if formattedDate != "" {
|
||
calloutText += " vom " + formattedDate
|
||
}
|
||
blocks = append(blocks, domain.DocumentBlock{
|
||
ID: domain.NewID("blk"),
|
||
Type: "callout",
|
||
Content: calloutText,
|
||
Level: 0,
|
||
})
|
||
if strings.TrimSpace(thought.Notes) != "" {
|
||
blocks = append(blocks, domain.DocumentBlock{
|
||
ID: domain.NewID("blk"),
|
||
Type: "bullet",
|
||
Content: strings.TrimSpace(thought.Notes),
|
||
Level: 0,
|
||
})
|
||
}
|
||
if len(thought.Subtasks) > 0 {
|
||
for _, st := range thought.Subtasks {
|
||
blocks = append(blocks, domain.DocumentBlock{
|
||
ID: domain.NewID("blk"),
|
||
Type: "todo",
|
||
Content: st.Title,
|
||
Checked: st.IsCompleted,
|
||
Level: 0,
|
||
})
|
||
}
|
||
}
|
||
if len(blocks) == 1 {
|
||
blocks = append(blocks, domain.DocumentBlock{
|
||
ID: domain.NewID("blk"),
|
||
Type: "bullet",
|
||
Content: "",
|
||
Level: 0,
|
||
})
|
||
}
|
||
doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false)
|
||
if err == nil && doc != nil {
|
||
convertedRefID = doc.ID
|
||
}
|
||
} else if convertTo == "list_item" && targetListID != "" {
|
||
newStatus = domain.StatusConverted
|
||
convertedType = "list_item"
|
||
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0)
|
||
if err == nil && item != nil {
|
||
convertedRefID = item.ID
|
||
}
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("invalid triage action: %s", action)
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`UPDATE thoughts
|
||
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?
|
||
WHERE id = ? AND user_id = ?`,
|
||
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, thoughtID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.GetThoughtByID(userID, thoughtID)
|
||
}
|
||
|
||
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask) (*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
|
||
}
|
||
if subtasks != nil {
|
||
thought.Subtasks = *subtasks
|
||
}
|
||
|
||
tagsJSON, _ := json.Marshal(thought.Tags)
|
||
subtasksJSON, _ := json.Marshal(thought.Subtasks)
|
||
completedInt := 0
|
||
if thought.IsCompleted {
|
||
completedInt = 1
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`,
|
||
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return thought, nil
|
||
}
|
||
|
||
func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
||
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return res.RowsAffected()
|
||
}
|
||
|
||
// --- DOCUMENTS (WORKSPACE) ---
|
||
|
||
func (s *Store) saveDocumentToDisk(doc *domain.Document) {
|
||
if s.docDir == "" || doc == nil {
|
||
return
|
||
}
|
||
userDir := filepath.Join(s.docDir, doc.UserID)
|
||
if doc.FolderPath != "" {
|
||
userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath))
|
||
}
|
||
_ = os.MkdirAll(userDir, 0755)
|
||
|
||
var md strings.Builder
|
||
md.WriteString(fmt.Sprintf("# %s %s\n\n", doc.Icon, doc.Title))
|
||
if len(doc.Blocks) > 0 {
|
||
for _, b := range doc.Blocks {
|
||
indent := strings.Repeat(" ", b.Level)
|
||
switch b.Type {
|
||
case "h1":
|
||
md.WriteString(fmt.Sprintf("%s- # %s\n", indent, b.Content))
|
||
case "h2":
|
||
md.WriteString(fmt.Sprintf("%s- ## %s\n", indent, b.Content))
|
||
case "h3":
|
||
md.WriteString(fmt.Sprintf("%s- ### %s\n", indent, b.Content))
|
||
case "todo":
|
||
chk := " "
|
||
if b.Checked {
|
||
chk = "x"
|
||
}
|
||
md.WriteString(fmt.Sprintf("%s- [%s] %s\n", indent, chk, b.Content))
|
||
case "bullet", "paragraph":
|
||
md.WriteString(fmt.Sprintf("%s- %s\n", indent, b.Content))
|
||
case "code":
|
||
md.WriteString(fmt.Sprintf("%s- ```%s\n%s %s\n%s ```\n", indent, b.Lang, indent, b.Content, indent))
|
||
case "callout":
|
||
md.WriteString(fmt.Sprintf("%s- > %s\n", indent, b.Content))
|
||
case "divider":
|
||
md.WriteString(fmt.Sprintf("%s- ---\n", indent))
|
||
case "table":
|
||
md.WriteString(fmt.Sprintf("%s- %s\n", indent, b.Content))
|
||
case "progress":
|
||
md.WriteString(fmt.Sprintf("%s- <!-- progress: %s -->\n", indent, b.Content))
|
||
default:
|
||
md.WriteString(fmt.Sprintf("%s- %s\n", indent, b.Content))
|
||
}
|
||
}
|
||
} else if doc.Content != "" {
|
||
md.WriteString(doc.Content + "\n\n")
|
||
}
|
||
|
||
targetPath := filepath.Join(userDir, doc.ID+".md")
|
||
_ = os.WriteFile(targetPath, []byte(md.String()), 0644)
|
||
}
|
||
|
||
func (s *Store) removeDocumentFromDisk(doc *domain.Document) {
|
||
if s.docDir == "" || doc == nil {
|
||
return
|
||
}
|
||
userDir := filepath.Join(s.docDir, doc.UserID)
|
||
if doc.FolderPath != "" {
|
||
userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath))
|
||
}
|
||
targetPath := filepath.Join(userDir, doc.ID+".md")
|
||
_ = os.Remove(targetPath)
|
||
}
|
||
|
||
func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) {
|
||
if strings.TrimSpace(title) == "" {
|
||
title = "Unbenannte Notiz"
|
||
}
|
||
if icon == "" {
|
||
icon = "📄"
|
||
}
|
||
if blocks == nil {
|
||
blocks = []domain.DocumentBlock{
|
||
{ID: domain.NewID("blk"), Type: "paragraph", Content: ""},
|
||
}
|
||
}
|
||
if tags == nil {
|
||
tags = []string{}
|
||
}
|
||
|
||
var plainBuilder strings.Builder
|
||
for _, b := range blocks {
|
||
if b.Content != "" {
|
||
plainBuilder.WriteString(b.Content)
|
||
plainBuilder.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
blocksJSON, _ := json.Marshal(blocks)
|
||
tagsJSON, _ := json.Marshal(tags)
|
||
now := domain.NowISO()
|
||
pinnedInt := 0
|
||
if isPinned {
|
||
pinnedInt = 1
|
||
}
|
||
|
||
doc := &domain.Document{
|
||
ID: domain.NewID("doc"),
|
||
UserID: userID,
|
||
SpaceID: spaceID,
|
||
Title: strings.TrimSpace(title),
|
||
Icon: icon,
|
||
Content: plainBuilder.String(),
|
||
Blocks: blocks,
|
||
Tags: tags,
|
||
FolderPath: folderPath,
|
||
IsPinned: isPinned,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||
doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if isPinned {
|
||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt)
|
||
}
|
||
s.saveDocumentToDisk(doc)
|
||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
||
return doc, nil
|
||
}
|
||
|
||
func scanDocument(rows interface {
|
||
Scan(dest ...any) error
|
||
}) (*domain.Document, error) {
|
||
d := &domain.Document{}
|
||
var blocksStr, tagsStr string
|
||
var isPinned, isArchived int
|
||
err := rows.Scan(
|
||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d.IsPinned = isPinned != 0
|
||
d.IsArchived = isArchived != 0
|
||
d.Blocks = []domain.DocumentBlock{}
|
||
d.Tags = []string{}
|
||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||
return d, nil
|
||
}
|
||
|
||
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
||
// 1. Fetch user's own documents (with per-user is_pinned from doc_pins)
|
||
rows, err := s.db.Query(
|
||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||
d.is_archived, d.created_at, d.updated_at
|
||
FROM documents d
|
||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||
WHERE d.user_id = ? AND d.is_archived = 0
|
||
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
||
userID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
docs := make([]domain.Document, 0)
|
||
for rows.Next() {
|
||
d, err := scanDocument(rows)
|
||
if err == nil {
|
||
d.Permission = "owner"
|
||
d.IsOwner = true
|
||
docs = append(docs, *d)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
// 2. Fetch documents shared with the user (with per-user is_pinned from doc_pins)
|
||
sharedRows, err := s.db.Query(
|
||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||
d.is_archived, d.created_at, d.updated_at,
|
||
s.permission, u.display_name
|
||
FROM documents d
|
||
JOIN doc_shares s ON d.id = s.doc_id
|
||
JOIN users u ON d.user_id = u.id
|
||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||
WHERE s.user_id = ? AND d.is_archived = 0
|
||
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
||
userID, userID,
|
||
)
|
||
if err == nil {
|
||
for sharedRows.Next() {
|
||
d := &domain.Document{}
|
||
var blocksStr, tagsStr string
|
||
var isPinned, isArchived int
|
||
var perm, ownerName string
|
||
if scanErr := sharedRows.Scan(
|
||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||
&perm, &ownerName,
|
||
); scanErr == nil {
|
||
d.IsPinned = isPinned != 0
|
||
d.IsArchived = isArchived != 0
|
||
d.Blocks = []domain.DocumentBlock{}
|
||
d.Tags = []string{}
|
||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||
d.Permission = perm
|
||
d.IsOwner = false
|
||
d.OwnerName = ownerName
|
||
docs = append(docs, *d)
|
||
}
|
||
}
|
||
_ = sharedRows.Close()
|
||
}
|
||
|
||
// 3. Attach shares to owned documents
|
||
for i := range docs {
|
||
if docs[i].IsOwner {
|
||
if shares, err := s.GetDocShares(docs[i].ID); err == nil {
|
||
docs[i].SharedWith = shares
|
||
}
|
||
}
|
||
}
|
||
|
||
return docs, nil
|
||
}
|
||
|
||
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
||
// Try owned document first (with per-user is_pinned)
|
||
row := s.db.QueryRow(
|
||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||
d.is_archived, d.created_at, d.updated_at
|
||
FROM documents d
|
||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||
WHERE d.id = ? AND d.user_id = ?`,
|
||
userID, docID, userID,
|
||
)
|
||
doc, err := scanDocument(row)
|
||
if err == nil {
|
||
doc.Permission = "owner"
|
||
doc.IsOwner = true
|
||
if shares, err := s.GetDocShares(doc.ID); err == nil {
|
||
doc.SharedWith = shares
|
||
}
|
||
return doc, nil
|
||
}
|
||
|
||
// Try shared document (with per-user is_pinned)
|
||
sharedRow := s.db.QueryRow(
|
||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||
d.is_archived, d.created_at, d.updated_at,
|
||
s.permission, u.display_name
|
||
FROM documents d
|
||
JOIN doc_shares s ON d.id = s.doc_id
|
||
JOIN users u ON d.user_id = u.id
|
||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||
WHERE d.id = ? AND s.user_id = ?`,
|
||
userID, docID, userID,
|
||
)
|
||
d := &domain.Document{}
|
||
var blocksStr, tagsStr string
|
||
var isPinned, isArchived int
|
||
var perm, ownerName string
|
||
err = sharedRow.Scan(
|
||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||
&perm, &ownerName,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d.IsPinned = isPinned != 0
|
||
d.IsArchived = isArchived != 0
|
||
d.Blocks = []domain.DocumentBlock{}
|
||
d.Tags = []string{}
|
||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||
d.Permission = perm
|
||
d.IsOwner = false
|
||
d.OwnerName = ownerName
|
||
if shares, err := s.GetDocShares(d.ID); err == nil {
|
||
d.SharedWith = shares
|
||
}
|
||
return d, nil
|
||
}
|
||
|
||
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) {
|
||
doc, err := s.GetDocumentByID(userID, docID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
|
||
|
||
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange {
|
||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||
}
|
||
|
||
if isPinned != nil {
|
||
doc.IsPinned = *isPinned
|
||
if *isPinned {
|
||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO())
|
||
if doc.IsOwner {
|
||
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 1 WHERE id = ?`, docID)
|
||
}
|
||
} else {
|
||
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
||
if doc.IsOwner {
|
||
_, _ = s.db.Exec(`UPDATE documents SET is_pinned = 0 WHERE id = ?`, docID)
|
||
}
|
||
}
|
||
}
|
||
|
||
if isOnlyPinChange {
|
||
return doc, nil
|
||
}
|
||
|
||
if title != nil {
|
||
doc.Title = strings.TrimSpace(*title)
|
||
}
|
||
if icon != nil && *icon != "" {
|
||
doc.Icon = *icon
|
||
}
|
||
if folderPath != nil {
|
||
doc.FolderPath = *folderPath
|
||
}
|
||
if blocks != nil {
|
||
doc.Blocks = *blocks
|
||
var plainBuilder strings.Builder
|
||
for _, b := range doc.Blocks {
|
||
if b.Content != "" {
|
||
plainBuilder.WriteString(b.Content)
|
||
plainBuilder.WriteString("\n")
|
||
}
|
||
}
|
||
doc.Content = plainBuilder.String()
|
||
}
|
||
if tags != nil {
|
||
doc.Tags = *tags
|
||
}
|
||
|
||
doc.UpdatedAt = domain.NowISO()
|
||
blocksJSON, _ := json.Marshal(doc.Blocks)
|
||
tagsJSON, _ := json.Marshal(doc.Tags)
|
||
|
||
_, err = s.db.Exec(
|
||
`UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, updated_at = ?
|
||
WHERE id = ?`,
|
||
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, doc.UpdatedAt, docID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.saveDocumentToDisk(doc)
|
||
if blocks != nil {
|
||
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
||
}
|
||
return doc, nil
|
||
}
|
||
|
||
func (s *Store) DeleteDocument(userID, docID string) error {
|
||
doc, err := s.GetDocumentByID(userID, docID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !doc.IsOwner {
|
||
// Collaborator removing their shared access
|
||
return s.DeleteDocShare(userID, docID, userID)
|
||
}
|
||
if doc != nil {
|
||
s.removeDocumentFromDisk(doc)
|
||
}
|
||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
|
||
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) reindexAllDocBlockTags() error {
|
||
rows, err := s.db.Query(`SELECT id, user_id, blocks_json FROM documents WHERE is_archived = 0`)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
type docInfo struct {
|
||
id, userID, blocksJSON string
|
||
}
|
||
var docs []docInfo
|
||
for rows.Next() {
|
||
var d docInfo
|
||
if err := rows.Scan(&d.id, &d.userID, &d.blocksJSON); err == nil {
|
||
docs = append(docs, d)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for _, d := range docs {
|
||
var blocks []domain.DocumentBlock
|
||
if err := json.Unmarshal([]byte(d.blocksJSON), &blocks); err == nil && len(blocks) > 0 {
|
||
_ = s.indexDocBlockTags(d.id, d.userID, blocks)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.DocumentBlock) error {
|
||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, docID)
|
||
if len(blocks) == 0 {
|
||
return nil
|
||
}
|
||
|
||
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO doc_block_tags (id, tag, user_id, doc_id, block_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer stmt.Close()
|
||
|
||
now := domain.NowISO()
|
||
for _, b := range blocks {
|
||
if b.Content == "" {
|
||
continue
|
||
}
|
||
tags := domain.ExtractTags(b.Content)
|
||
for _, tag := range tags {
|
||
tagID := domain.NewID("dbtag")
|
||
_, _ = stmt.Exec(tagID, tag, userID, docID, b.ID, now)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count
|
||
FROM doc_block_tags t
|
||
JOIN documents d ON t.doc_id = d.id
|
||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||
WHERE (d.user_id = ? OR s.user_id = ?)
|
||
AND d.is_archived = 0
|
||
GROUP BY t.tag
|
||
ORDER BY count DESC, t.tag ASC`,
|
||
userID, userID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
results := make([]domain.TagSummary, 0)
|
||
for rows.Next() {
|
||
var ts domain.TagSummary
|
||
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
|
||
results = append(results, ts)
|
||
}
|
||
}
|
||
return results, nil
|
||
}
|
||
|
||
func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
||
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
|
||
if cleanTag == "" {
|
||
return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil
|
||
}
|
||
|
||
rows, err := s.db.Query(
|
||
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
||
COALESCE(u.display_name, '') as owner_name,
|
||
COALESCE(s.permission, '') as shared_permission
|
||
FROM doc_block_tags t
|
||
JOIN documents d ON t.doc_id = d.id
|
||
LEFT JOIN users u ON d.user_id = u.id
|
||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||
WHERE (d.user_id = ? OR s.user_id = ?)
|
||
AND t.tag = ?
|
||
AND d.is_archived = 0
|
||
ORDER BY d.updated_at DESC`,
|
||
userID, userID, userID, cleanTag,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
type rawMatch struct {
|
||
blockID string
|
||
docID string
|
||
title string
|
||
icon string
|
||
folderPath string
|
||
blocksJSON string
|
||
updatedAt string
|
||
docUserID string
|
||
ownerName string
|
||
sharedPermission string
|
||
}
|
||
|
||
var rawMatches []rawMatch
|
||
for rows.Next() {
|
||
var rm rawMatch
|
||
if err := rows.Scan(&rm.blockID, &rm.docID, &rm.title, &rm.icon, &rm.folderPath, &rm.blocksJSON, &rm.updatedAt, &rm.docUserID, &rm.ownerName, &rm.sharedPermission); err == nil {
|
||
rawMatches = append(rawMatches, rm)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
// Group rawMatches by docID
|
||
docMap := make(map[string][]rawMatch)
|
||
var docOrder []string
|
||
for _, rm := range rawMatches {
|
||
if _, exists := docMap[rm.docID]; !exists {
|
||
docOrder = append(docOrder, rm.docID)
|
||
}
|
||
docMap[rm.docID] = append(docMap[rm.docID], rm)
|
||
}
|
||
|
||
totalMatches := 0
|
||
docGroups := make([]domain.TagDocumentGroup, 0, len(docOrder))
|
||
|
||
for _, docID := range docOrder {
|
||
matches := docMap[docID]
|
||
first := matches[0]
|
||
|
||
var blocks []domain.DocumentBlock
|
||
_ = json.Unmarshal([]byte(first.blocksJSON), &blocks)
|
||
|
||
blockIdxMap := make(map[string]int, len(blocks))
|
||
for i, b := range blocks {
|
||
blockIdxMap[b.ID] = i
|
||
}
|
||
|
||
matchedBlocks := make([]domain.MatchedTagBlock, 0)
|
||
processedBlockIDs := make(map[string]bool)
|
||
|
||
for _, m := range matches {
|
||
if processedBlockIDs[m.blockID] {
|
||
continue
|
||
}
|
||
idx, ok := blockIdxMap[m.blockID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
rootBlock := blocks[idx]
|
||
processedBlockIDs[rootBlock.ID] = true
|
||
|
||
children := make([]domain.DocumentBlock, 0)
|
||
rootLevel := rootBlock.Level
|
||
for i := idx + 1; i < len(blocks); i++ {
|
||
if blocks[i].Level > rootLevel {
|
||
children = append(children, blocks[i])
|
||
processedBlockIDs[blocks[i].ID] = true
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
matchedBlocks = append(matchedBlocks, domain.MatchedTagBlock{
|
||
RootBlock: rootBlock,
|
||
Children: children,
|
||
})
|
||
totalMatches++
|
||
}
|
||
|
||
if len(matchedBlocks) == 0 {
|
||
continue
|
||
}
|
||
|
||
isOwner := first.docUserID == userID
|
||
permission := "read"
|
||
if isOwner {
|
||
permission = "owner"
|
||
} else if first.sharedPermission != "" {
|
||
permission = first.sharedPermission
|
||
}
|
||
|
||
docGroups = append(docGroups, domain.TagDocumentGroup{
|
||
DocID: first.docID,
|
||
Title: first.title,
|
||
Icon: first.icon,
|
||
FolderPath: first.folderPath,
|
||
UpdatedAt: first.updatedAt,
|
||
IsOwner: isOwner,
|
||
Permission: permission,
|
||
OwnerName: first.ownerName,
|
||
MatchedBlocks: matchedBlocks,
|
||
})
|
||
}
|
||
|
||
return &domain.TagDetails{
|
||
Tag: cleanTag,
|
||
Documents: docGroups,
|
||
TotalMatches: totalMatches,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Store) GetFolders(userID string) ([]string, error) {
|
||
folderSet := make(map[string]bool)
|
||
|
||
// 1. Collect from SQLite documents (owned by user or shared with user)
|
||
rows, err := s.db.Query(
|
||
`SELECT DISTINCT folder_path FROM documents
|
||
WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?))
|
||
AND is_archived = 0 AND folder_path != ''`,
|
||
userID, userID,
|
||
)
|
||
if err == nil {
|
||
for rows.Next() {
|
||
var fp string
|
||
if err := rows.Scan(&fp); err == nil && fp != "" {
|
||
clean := filepath.ToSlash(filepath.Clean(fp))
|
||
parts := strings.Split(clean, "/")
|
||
var cur string
|
||
for _, p := range parts {
|
||
if p != "" && p != "." {
|
||
if cur == "" {
|
||
cur = p
|
||
} else {
|
||
cur = cur + "/" + p
|
||
}
|
||
folderSet[cur] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
}
|
||
|
||
// 2. Scan physical directories on disk in docDir/userID
|
||
if s.docDir != "" {
|
||
userDir := filepath.Join(s.docDir, userID)
|
||
if fi, err := os.Stat(userDir); err == nil && fi.IsDir() {
|
||
_ = filepath.Walk(userDir, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil || !info.IsDir() {
|
||
return nil
|
||
}
|
||
if path == userDir {
|
||
return nil
|
||
}
|
||
rel, err := filepath.Rel(userDir, path)
|
||
if err == nil && rel != "" && rel != "." {
|
||
clean := filepath.ToSlash(filepath.Clean(rel))
|
||
parts := strings.Split(clean, "/")
|
||
var cur string
|
||
for _, p := range parts {
|
||
if p != "" && p != "." {
|
||
if cur == "" {
|
||
cur = p
|
||
} else {
|
||
cur = cur + "/" + p
|
||
}
|
||
folderSet[cur] = true
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
}
|
||
|
||
result := make([]string, 0, len(folderSet))
|
||
for f := range folderSet {
|
||
result = append(result, f)
|
||
}
|
||
sort.Strings(result)
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Store) CreateFolder(userID, folderPath string) (string, error) {
|
||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
||
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
||
return "", fmt.Errorf("ungültiger Ordnerpfad")
|
||
}
|
||
|
||
if s.docDir != "" {
|
||
targetDir := filepath.Join(s.docDir, userID, clean)
|
||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
return clean, nil
|
||
}
|
||
|
||
func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
||
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
||
if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
|
||
return nil, fmt.Errorf("ungültiger Ordnerpfad")
|
||
}
|
||
|
||
// Find all documents in this folder and subfolders
|
||
rows, err := s.db.Query(
|
||
`SELECT id FROM documents
|
||
WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`,
|
||
userID, clean, clean+"/%",
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var deletedDocIDs []string
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err == nil {
|
||
deletedDocIDs = append(deletedDocIDs, id)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
// Delete from SQLite, pins, shares, tags
|
||
for _, id := range deletedDocIDs {
|
||
_, _ = s.db.Exec(`DELETE FROM doc_block_tags WHERE doc_id = ?`, id)
|
||
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
|
||
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
|
||
_, _ = s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, id, userID)
|
||
}
|
||
|
||
// Remove physical folder and subdirectories from disk
|
||
if s.docDir != "" {
|
||
targetDir := filepath.Join(s.docDir, userID, clean)
|
||
_ = os.RemoveAll(targetDir)
|
||
}
|
||
|
||
return deletedDocIDs, nil
|
||
}
|
||
|
||
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
|
||
FROM doc_shares s
|
||
JOIN users u ON s.user_id = u.id
|
||
WHERE s.doc_id = ?
|
||
ORDER BY s.created_at ASC`,
|
||
docID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
shares := make([]domain.DocShare, 0)
|
||
for rows.Next() {
|
||
var sh domain.DocShare
|
||
if err := rows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||
shares = append(shares, sh)
|
||
}
|
||
}
|
||
return shares, nil
|
||
}
|
||
|
||
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
|
||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !doc.IsOwner && doc.Permission != "write" {
|
||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
||
}
|
||
|
||
if permission != "read" && permission != "write" {
|
||
permission = "read"
|
||
}
|
||
|
||
// Resolve target user by ID, username, or Email
|
||
var targetUser *domain.User
|
||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||
if strings.HasPrefix(cleanTarget, "usr_") {
|
||
targetUser, _ = s.GetUserByID(cleanTarget)
|
||
}
|
||
if targetUser == nil {
|
||
targetUser, _ = s.GetUserByLogin(cleanTarget)
|
||
}
|
||
if targetUser == nil {
|
||
targetUser, err = s.GetUserByID(cleanTarget)
|
||
}
|
||
if err != nil || targetUser == nil {
|
||
return nil, fmt.Errorf("benutzer nicht gefunden")
|
||
}
|
||
|
||
if targetUser.ID == doc.UserID {
|
||
return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer")
|
||
}
|
||
|
||
now := domain.NowISO()
|
||
shareID := domain.NewID("share")
|
||
_, err = s.db.Exec(
|
||
`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(doc_id, user_id) DO UPDATE SET permission = excluded.permission`,
|
||
shareID, docID, targetUser.ID, permission, now, currentUserID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &domain.DocShare{
|
||
ID: shareID,
|
||
DocID: docID,
|
||
UserID: targetUser.ID,
|
||
UserEmail: targetUser.Email,
|
||
DisplayName: targetUser.DisplayName,
|
||
Permission: permission,
|
||
CreatedBy: currentUserID,
|
||
CreatedAt: now,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Store) DeleteDocShare(currentUserID, docID, targetUserID string) error {
|
||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetUserID {
|
||
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
|
||
}
|
||
|
||
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND user_id = ?`, docID, targetUserID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, email, display_name, role, is_active, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`,
|
||
currentUserID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
users := make([]domain.User, 0)
|
||
for rows.Next() {
|
||
var u domain.User
|
||
var activeInt int
|
||
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.CreatedAt); err == nil {
|
||
u.IsActive = activeInt == 1
|
||
users = append(users, u)
|
||
}
|
||
}
|
||
return users, nil
|
||
}
|
||
|
||
// --- SPACES & REALTIME LISTS ---
|
||
|
||
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
|
||
if strings.TrimSpace(name) == "" {
|
||
return nil, errors.New("space name required")
|
||
}
|
||
if icon == "" {
|
||
icon = "🏠"
|
||
}
|
||
now := domain.NowISO()
|
||
inviteCode := strings.ToUpper(domain.NewID("inv")[4:12])
|
||
personalInt := 0
|
||
if isPersonal {
|
||
personalInt = 1
|
||
}
|
||
|
||
sp := &domain.Space{
|
||
ID: domain.NewID("spc"),
|
||
Name: strings.TrimSpace(name),
|
||
Icon: icon,
|
||
Description: strings.TrimSpace(description),
|
||
InviteCode: inviteCode,
|
||
IsPersonal: isPersonal,
|
||
OwnerID: ownerID,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, _ = s.db.Exec(
|
||
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`,
|
||
sp.ID, ownerID, now,
|
||
)
|
||
return s.GetSpaceByID(sp.ID)
|
||
}
|
||
|
||
func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) {
|
||
inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode))
|
||
var spaceID string
|
||
err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID)
|
||
if err != nil {
|
||
return nil, errors.New("Ungültiger Einladungscode")
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`,
|
||
spaceID, userID, domain.NowISO(),
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetSpaceByID(spaceID)
|
||
}
|
||
|
||
func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) {
|
||
sp := &domain.Space{}
|
||
var personalInt int
|
||
err := s.db.QueryRow(
|
||
`SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`,
|
||
spaceID,
|
||
).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sp.IsPersonal = personalInt != 0
|
||
sp.Members, _ = s.GetSpaceMembers(spaceID)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount)
|
||
return sp, nil
|
||
}
|
||
|
||
func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
|
||
FROM space_members sm
|
||
JOIN users u ON u.id = sm.user_id
|
||
WHERE sm.space_id = ?
|
||
ORDER BY sm.joined_at ASC`,
|
||
spaceID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
members := make([]domain.SpaceMember, 0)
|
||
for rows.Next() {
|
||
var m domain.SpaceMember
|
||
if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
|
||
members = append(members, m)
|
||
}
|
||
}
|
||
return members, nil
|
||
}
|
||
|
||
func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, role string) (*domain.SpaceMember, error) {
|
||
// 1. Check if current user is member of space
|
||
var myRole string
|
||
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||
if err != nil {
|
||
return nil, errors.New("keine Berechtigung für diesen Bereich")
|
||
}
|
||
|
||
// 2. Resolve target user by ID, username or email
|
||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||
var targetUser *domain.User
|
||
if strings.HasPrefix(cleanTarget, "usr_") {
|
||
targetUser, _ = s.GetUserByID(cleanTarget)
|
||
}
|
||
if targetUser == nil {
|
||
targetUser, _ = s.GetUserByLogin(cleanTarget)
|
||
}
|
||
if targetUser == nil {
|
||
targetUser, err = s.GetUserByID(cleanTarget)
|
||
}
|
||
if err != nil || targetUser == nil {
|
||
return nil, errors.New("benutzer nicht gefunden")
|
||
}
|
||
|
||
if role != "owner" && role != "editor" && role != "viewer" {
|
||
role = "editor"
|
||
}
|
||
|
||
now := domain.NowISO()
|
||
_, err = s.db.Exec(
|
||
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
|
||
ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`,
|
||
spaceID, targetUser.ID, role, now,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &domain.SpaceMember{
|
||
SpaceID: spaceID,
|
||
UserID: targetUser.ID,
|
||
DisplayName: targetUser.DisplayName,
|
||
Email: targetUser.Email,
|
||
Role: role,
|
||
JoinedAt: now,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error {
|
||
var myRole string
|
||
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||
if err != nil {
|
||
return errors.New("keine Berechtigung für diesen Bereich")
|
||
}
|
||
|
||
if currentUserID != targetUserID && myRole != "owner" {
|
||
return errors.New("nur der Eigentümer kann andere Mitglieder entfernen")
|
||
}
|
||
|
||
var targetRole string
|
||
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID).Scan(&targetRole)
|
||
if targetRole == "owner" {
|
||
var ownerCount int
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount)
|
||
if ownerCount <= 1 {
|
||
return errors.New("der letzte Eigentümer kann den Bereich nicht verlassen")
|
||
}
|
||
}
|
||
|
||
_, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
|
||
FROM spaces s
|
||
JOIN space_members sm ON sm.space_id = s.id
|
||
WHERE sm.user_id = ?
|
||
ORDER BY s.created_at ASC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
spaces := make([]domain.Space, 0)
|
||
for rows.Next() {
|
||
var sp domain.Space
|
||
var personalInt int
|
||
if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil {
|
||
sp.IsPersonal = personalInt != 0
|
||
spaces = append(spaces, sp)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range spaces {
|
||
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
|
||
}
|
||
return spaces, nil
|
||
}
|
||
|
||
func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.SharedList, error) {
|
||
if strings.TrimSpace(title) == "" {
|
||
return nil, errors.New("list title required")
|
||
}
|
||
if icon == "" {
|
||
icon = "🛒"
|
||
}
|
||
if listType == "" {
|
||
listType = "shopping"
|
||
}
|
||
now := domain.NowISO()
|
||
lst := &domain.SharedList{
|
||
ID: domain.NewID("lst"),
|
||
SpaceID: spaceID,
|
||
Title: strings.TrimSpace(title),
|
||
Icon: icon,
|
||
ListType: listType,
|
||
Items: []domain.ListItem{},
|
||
UpdatedAt: now,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return lst, nil
|
||
}
|
||
|
||
func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at
|
||
FROM lists WHERE space_id = ? ORDER BY created_at ASC`,
|
||
spaceID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lists := make([]domain.SharedList, 0)
|
||
for rows.Next() {
|
||
var l domain.SharedList
|
||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||
lists = append(lists, l)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range lists {
|
||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||
}
|
||
return lists, nil
|
||
}
|
||
|
||
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at
|
||
FROM lists l
|
||
JOIN space_members sm ON sm.space_id = l.space_id
|
||
WHERE sm.user_id = ?
|
||
ORDER BY l.updated_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lists := make([]domain.SharedList, 0)
|
||
for rows.Next() {
|
||
var l domain.SharedList
|
||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||
lists = append(lists, l)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range lists {
|
||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||
}
|
||
return lists, nil
|
||
}
|
||
|
||
func (s *Store) GetListByID(listID string) (*domain.SharedList, error) {
|
||
var l domain.SharedList
|
||
err := s.db.QueryRow(
|
||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`,
|
||
listID,
|
||
).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
l.Items, _ = s.GetListItems(l.ID)
|
||
return &l, nil
|
||
}
|
||
|
||
func (s *Store) DeleteList(listID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||
FROM list_items WHERE list_id = ?
|
||
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||
listID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]domain.ListItem, 0)
|
||
for rows.Next() {
|
||
var it domain.ListItem
|
||
var doneInt int
|
||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
|
||
it.IsCompleted = doneInt != 0
|
||
items = append(items, it)
|
||
}
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int) (*domain.ListItem, error) {
|
||
content = strings.TrimSpace(content)
|
||
if content == "" {
|
||
return nil, errors.New("item content cannot be empty")
|
||
}
|
||
|
||
category = strings.TrimSpace(category)
|
||
// Auto-extract hashtag if category is empty
|
||
if category == "" && strings.Contains(content, "#") {
|
||
re := regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||
if m := re.FindStringSubmatch(content); len(m) > 1 {
|
||
category = m[1]
|
||
cleaned := strings.TrimSpace(re.ReplaceAllString(content, ""))
|
||
if cleaned != "" {
|
||
content = cleaned
|
||
}
|
||
}
|
||
} else if category != "" {
|
||
category = strings.TrimPrefix(category, "#")
|
||
}
|
||
|
||
now := domain.NowISO()
|
||
it := &domain.ListItem{
|
||
ID: domain.NewID("itm"),
|
||
ListID: listID,
|
||
Content: content,
|
||
Category: category,
|
||
Quantity: strings.TrimSpace(quantity),
|
||
IsCompleted: false,
|
||
SortOrder: sortOrder,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at)
|
||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
|
||
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, listID)
|
||
return it, nil
|
||
}
|
||
|
||
func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName string) (*domain.ListItem, error) {
|
||
now := domain.NowISO()
|
||
doneInt := 0
|
||
completedAt := ""
|
||
completedBy := ""
|
||
completedByName := ""
|
||
if isCompleted {
|
||
doneInt = 1
|
||
completedAt = now
|
||
completedBy = userID
|
||
completedByName = userName
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`UPDATE list_items SET is_completed = ?, completed_by = ?, completed_by_name = ?, completed_at = ? WHERE id = ?`,
|
||
doneInt, completedBy, completedByName, completedAt, itemID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var it domain.ListItem
|
||
var dInt int
|
||
err = s.db.QueryRow(
|
||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||
FROM list_items WHERE id = ?`,
|
||
itemID,
|
||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
it.IsCompleted = dInt != 0
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||
return &it, nil
|
||
}
|
||
|
||
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||
var listID string
|
||
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
||
_, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, itemID)
|
||
if listID != "" {
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||
}
|
||
return listID, err
|
||
}
|
||
|
||
func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
|
||
res, err := s.db.Exec(`DELETE FROM list_items WHERE list_id = ? AND is_completed = 1`, listID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||
return res.RowsAffected()
|
||
}
|
||
|
||
// --- API KEYS & WEBHOOKS ---
|
||
|
||
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
||
if strings.TrimSpace(name) == "" {
|
||
name = "Smarthome / Webhook Key"
|
||
}
|
||
rawKey, prefix, hash := auth.GenerateAPIKey()
|
||
k := &domain.APIKey{
|
||
ID: domain.NewID("key"),
|
||
UserID: userID,
|
||
Name: strings.TrimSpace(name),
|
||
KeyPrefix: prefix,
|
||
KeyHash: hash,
|
||
RawKey: rawKey,
|
||
Scopes: "ingest,read",
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return k, nil
|
||
}
|
||
|
||
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
|
||
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
keys := make([]domain.APIKey, 0)
|
||
for rows.Next() {
|
||
var k domain.APIKey
|
||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
|
||
keys = append(keys, k)
|
||
}
|
||
}
|
||
return keys, nil
|
||
}
|
||
|
||
func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||
var keyID, userID string
|
||
err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID)
|
||
if err != nil {
|
||
return nil, errors.New("invalid API key")
|
||
}
|
||
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
||
return s.GetUserByID(userID)
|
||
}
|
||
|
||
// --- STATS, SEARCH & EXPORT ---
|
||
|
||
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
|
||
st := &domain.DashboardStats{}
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 0`, userID).Scan(&st.TasksOpenCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 1`, userID).Scan(&st.TasksDoneCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists l JOIN space_members sm ON sm.space_id = l.space_id WHERE sm.user_id = ?`, userID).Scan(&st.ListsCount)
|
||
|
||
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
|
||
docRows, err := s.db.Query(`
|
||
SELECT blocks_json FROM documents
|
||
WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?))
|
||
AND is_archived = 0`,
|
||
userID, userID,
|
||
)
|
||
if err == nil {
|
||
for docRows.Next() {
|
||
var bj string
|
||
if err := docRows.Scan(&bj); err == nil && bj != "" {
|
||
var blocks []domain.DocumentBlock
|
||
if err := json.Unmarshal([]byte(bj), &blocks); err == nil {
|
||
for _, b := range blocks {
|
||
if b.Type == "todo" && !b.Checked {
|
||
st.DocsTodosOpenCount++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ = docRows.Close()
|
||
}
|
||
|
||
return st, nil
|
||
}
|
||
|
||
func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
|
||
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||
|
||
tRows, err := s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||
FROM thoughts WHERE user_id = ? AND LOWER(content) LIKE ? ORDER BY created_at DESC LIMIT 30`,
|
||
userID, q,
|
||
)
|
||
thoughts := make([]domain.Thought, 0)
|
||
if err == nil {
|
||
for tRows.Next() {
|
||
if t, err := scanThought(tRows); err == nil {
|
||
thoughts = append(thoughts, *t)
|
||
}
|
||
}
|
||
_ = tRows.Close()
|
||
}
|
||
|
||
dRows, err := s.db.Query(
|
||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||
d.is_archived, d.created_at, d.updated_at
|
||
FROM documents d
|
||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||
WHERE d.user_id = ? AND (LOWER(d.title) LIKE ? OR LOWER(d.content) LIKE ?) ORDER BY is_pinned DESC, d.updated_at DESC LIMIT 20`,
|
||
userID, userID, q, q,
|
||
)
|
||
docs := make([]domain.Document, 0)
|
||
if err == nil {
|
||
for dRows.Next() {
|
||
if d, err := scanDocument(dRows); err == nil {
|
||
docs = append(docs, *d)
|
||
}
|
||
}
|
||
_ = dRows.Close()
|
||
}
|
||
|
||
return thoughts, docs, nil
|
||
}
|
||
|
||
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||
user, err := s.GetUserByID(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
thoughts, _ := s.GetThoughts(userID, "all", 5000)
|
||
docs, _ := s.GetDocuments(userID)
|
||
spaces, _ := s.GetSpacesForUser(userID)
|
||
lists, _ := s.GetAllListsForUser(userID)
|
||
taskLists, _ := s.GetTaskLists(userID)
|
||
|
||
return &domain.FullExport{
|
||
ExportedAt: domain.NowISO(),
|
||
Version: "1.0.0",
|
||
User: *user,
|
||
Thoughts: thoughts,
|
||
Documents: docs,
|
||
Spaces: spaces,
|
||
Lists: lists,
|
||
TaskLists: taskLists,
|
||
}, nil
|
||
}
|
||
|
||
// --- TASK LISTS ---
|
||
|
||
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string) (*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,
|
||
TeamID: teamID,
|
||
Title: strings.TrimSpace(title),
|
||
Icon: icon,
|
||
Color: color,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
IsOwner: true,
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if teamID != "" {
|
||
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, userID)
|
||
}
|
||
return s.GetTaskListByID(userID, tl.ID)
|
||
}
|
||
|
||
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy string) (*domain.TaskListShare, error) {
|
||
if targetType != "team" && targetType != "user" {
|
||
return nil, errors.New("ungültiger Freigabetyp")
|
||
}
|
||
targetID = strings.TrimSpace(targetID)
|
||
if targetID == "" {
|
||
return nil, errors.New("Ziel-ID erforderlich")
|
||
}
|
||
now := domain.NowISO()
|
||
share := &domain.TaskListShare{
|
||
ID: domain.NewID("tls"),
|
||
TaskListID: taskListID,
|
||
TargetType: targetType,
|
||
TargetID: targetID,
|
||
CreatedAt: now,
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(task_list_id, target_type, target_id) DO NOTHING`,
|
||
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if targetType == "team" {
|
||
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID)
|
||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
|
||
} else {
|
||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
|
||
}
|
||
return share, nil
|
||
}
|
||
|
||
func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error {
|
||
var targetType, targetID string
|
||
_ = s.db.QueryRow(`SELECT target_type, target_id FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, taskListID, shareIDOrTargetID, shareIDOrTargetID).Scan(&targetType, &targetID)
|
||
|
||
_, err := s.db.Exec(
|
||
`DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`,
|
||
taskListID, shareIDOrTargetID, shareIDOrTargetID,
|
||
)
|
||
if targetType == "team" {
|
||
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID)
|
||
}
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, task_list_id, target_type, target_id, created_at
|
||
FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`,
|
||
taskListID,
|
||
)
|
||
if err != nil {
|
||
return []domain.TaskListShare{}, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
shares := make([]domain.TaskListShare, 0)
|
||
for rows.Next() {
|
||
var sh domain.TaskListShare
|
||
if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.CreatedAt); err == nil {
|
||
shares = append(shares, sh)
|
||
}
|
||
}
|
||
for i := range shares {
|
||
if shares[i].TargetType == "team" {
|
||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName)
|
||
} else {
|
||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email)
|
||
}
|
||
}
|
||
return shares, nil
|
||
}
|
||
|
||
func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember {
|
||
memberMap := make(map[string]domain.TaskListMember)
|
||
|
||
// 1. Owner
|
||
var ownerName, ownerEmail string
|
||
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail)
|
||
memberMap[tl.UserID] = domain.TaskListMember{
|
||
UserID: tl.UserID,
|
||
DisplayName: ownerName,
|
||
Email: ownerEmail,
|
||
Role: "owner",
|
||
}
|
||
|
||
// 2. Team members if tl.TeamID != ""
|
||
if tl.TeamID != "" {
|
||
rows, err := s.db.Query(
|
||
`SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
||
tl.TeamID,
|
||
)
|
||
if err == nil {
|
||
for rows.Next() {
|
||
var m domain.TaskListMember
|
||
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
||
m.Role = "member"
|
||
memberMap[m.UserID] = m
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
}
|
||
}
|
||
|
||
// 3. From task_list_shares
|
||
for _, sh := range tl.Shares {
|
||
if sh.TargetType == "team" {
|
||
rows, err := s.db.Query(
|
||
`SELECT u.id, u.display_name, u.email FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
|
||
sh.TargetID,
|
||
)
|
||
if err == nil {
|
||
for rows.Next() {
|
||
var m domain.TaskListMember
|
||
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
||
m.Role = "member"
|
||
memberMap[m.UserID] = m
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
}
|
||
} else if sh.TargetType == "user" {
|
||
var m domain.TaskListMember
|
||
if err := s.db.QueryRow(`SELECT id, display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email); err == nil {
|
||
m.Role = "member"
|
||
memberMap[m.UserID] = m
|
||
}
|
||
}
|
||
}
|
||
|
||
result := make([]domain.TaskListMember, 0, len(memberMap))
|
||
for _, m := range memberMap {
|
||
result = append(result, m)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at
|
||
FROM task_lists
|
||
WHERE user_id = ?
|
||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||
OR id IN (
|
||
SELECT task_list_id FROM task_list_shares
|
||
WHERE (target_type = 'user' AND target_id = ?)
|
||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||
)
|
||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||
ORDER BY updated_at DESC`,
|
||
userID, userID, userID, userID, userID,
|
||
)
|
||
if err != nil {
|
||
return []domain.TaskList{}, err
|
||
}
|
||
|
||
list := make([]domain.TaskList, 0)
|
||
for rows.Next() {
|
||
var tl domain.TaskList
|
||
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||
tl.IsOwner = (tl.UserID == userID)
|
||
list = append(list, tl)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range list {
|
||
list[i].Members = make([]domain.TaskListMember, 0)
|
||
list[i].Shares = make([]domain.TaskListShare, 0)
|
||
|
||
_ = s.db.QueryRow(
|
||
`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`,
|
||
list[i].ID,
|
||
).Scan(&list[i].TasksCount)
|
||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName)
|
||
|
||
if list[i].TeamID != "" {
|
||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, list[i].TeamID).Scan(&list[i].TeamName)
|
||
}
|
||
|
||
shares, _ := s.GetTaskListShares(list[i].ID)
|
||
list[i].Shares = shares
|
||
list[i].Members = s.getMembersForTaskList(&list[i])
|
||
}
|
||
return list, nil
|
||
}
|
||
|
||
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
|
||
var tl domain.TaskList
|
||
err := s.db.QueryRow(
|
||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at
|
||
FROM task_lists
|
||
WHERE id = ? AND (
|
||
user_id = ?
|
||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||
OR id IN (
|
||
SELECT task_list_id FROM task_list_shares
|
||
WHERE (target_type = 'user' AND target_id = ?)
|
||
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||
)
|
||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||
)`,
|
||
listID, userID, userID, userID, userID, userID,
|
||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tl.IsOwner = (tl.UserID == userID)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
|
||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
|
||
if tl.TeamID != "" {
|
||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, tl.TeamID).Scan(&tl.TeamName)
|
||
}
|
||
shares, _ := s.GetTaskListShares(tl.ID)
|
||
tl.Shares = shares
|
||
tl.Members = s.getMembersForTaskList(&tl)
|
||
return &tl, nil
|
||
}
|
||
|
||
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string) (*domain.TaskList, error) {
|
||
tl, err := s.GetTaskListByID(userID, listID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if title != nil {
|
||
tl.Title = strings.TrimSpace(*title)
|
||
}
|
||
if icon != nil && *icon != "" {
|
||
tl.Icon = *icon
|
||
}
|
||
if color != nil && *color != "" {
|
||
tl.Color = *color
|
||
}
|
||
if spaceID != nil {
|
||
tl.SpaceID = *spaceID
|
||
}
|
||
if teamID != nil {
|
||
oldTeamID := tl.TeamID
|
||
tl.TeamID = *teamID
|
||
if oldTeamID != "" && oldTeamID != *teamID {
|
||
_ = s.RemoveTaskListShare(listID, oldTeamID)
|
||
}
|
||
if *teamID != "" {
|
||
_, _ = s.AddTaskListShare(listID, "team", *teamID, userID)
|
||
}
|
||
}
|
||
tl.UpdatedAt = domain.NowISO()
|
||
_, err = s.db.Exec(
|
||
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, updated_at = ? WHERE id = ?`,
|
||
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, tl.UpdatedAt, listID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetTaskListByID(userID, listID)
|
||
}
|
||
|
||
func (s *Store) DeleteTaskList(userID, listID string) error {
|
||
_, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID)
|
||
_, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID)
|
||
_, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID)
|
||
return err
|
||
}
|
||
|
||
// --- ORGANIZATIONS & TEAMS ---
|
||
|
||
func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization, error) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return nil, errors.New("organization name required")
|
||
}
|
||
org := &domain.Organization{
|
||
ID: domain.NewID("org"),
|
||
Name: name,
|
||
OwnerID: ownerID,
|
||
Teams: []domain.Team{},
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO organizations (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`,
|
||
org.ID, org.Name, org.OwnerID, org.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return org, nil
|
||
}
|
||
|
||
func (s *Store) DeleteOrganization(orgID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
|
||
viewer, _ := s.GetUserByID(userID)
|
||
var rows *sql.Rows
|
||
var err error
|
||
|
||
if viewer != nil && (viewer.Role == "admin" || viewer.Role == "team_admin") {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`,
|
||
)
|
||
} else {
|
||
rows, err = s.db.Query(
|
||
`SELECT DISTINCT o.id, o.name, o.owner_id, o.created_at
|
||
FROM organizations o
|
||
LEFT JOIN teams t ON t.org_id = o.id
|
||
LEFT JOIN team_members tm ON tm.team_id = t.id
|
||
WHERE o.owner_id = ? OR tm.user_id = ?
|
||
ORDER BY o.created_at ASC`,
|
||
userID, userID,
|
||
)
|
||
}
|
||
if err != nil {
|
||
return []domain.Organization{}, err
|
||
}
|
||
|
||
orgs := make([]domain.Organization, 0)
|
||
for rows.Next() {
|
||
var o domain.Organization
|
||
if err := rows.Scan(&o.ID, &o.Name, &o.OwnerID, &o.CreatedAt); err == nil {
|
||
o.Teams = []domain.Team{}
|
||
orgs = append(orgs, o)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range orgs {
|
||
teams, _ := s.GetTeamsForOrg(orgs[i].ID)
|
||
orgs[i].Teams = teams
|
||
}
|
||
return orgs, nil
|
||
}
|
||
|
||
func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return nil, errors.New("team name required")
|
||
}
|
||
team := &domain.Team{
|
||
ID: domain.NewID("team"),
|
||
OrgID: orgID,
|
||
Name: name,
|
||
Description: strings.TrimSpace(description),
|
||
Members: []domain.TeamMember{},
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return team, nil
|
||
}
|
||
|
||
func (s *Store) DeleteTeam(teamID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetAllTeams() ([]domain.Team, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, org_id, name, description, created_at FROM teams ORDER BY name COLLATE NOCASE ASC`,
|
||
)
|
||
if err != nil {
|
||
return []domain.Team{}, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
teams := make([]domain.Team, 0)
|
||
for rows.Next() {
|
||
var t domain.Team
|
||
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
|
||
t.Members = []domain.TeamMember{}
|
||
teams = append(teams, t)
|
||
}
|
||
}
|
||
for i := range teams {
|
||
members, _ := s.GetTeamMembers(teams[i].ID)
|
||
teams[i].Members = members
|
||
}
|
||
return teams, nil
|
||
}
|
||
|
||
func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`,
|
||
orgID,
|
||
)
|
||
if err != nil {
|
||
return []domain.Team{}, err
|
||
}
|
||
|
||
teams := make([]domain.Team, 0)
|
||
for rows.Next() {
|
||
var t domain.Team
|
||
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
|
||
t.Members = []domain.TeamMember{}
|
||
teams = append(teams, t)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range teams {
|
||
members, _ := s.GetTeamMembers(teams[i].ID)
|
||
teams[i].Members = members
|
||
}
|
||
return teams, nil
|
||
}
|
||
|
||
func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
|
||
FROM teams t
|
||
JOIN team_members tm ON tm.team_id = t.id
|
||
WHERE tm.user_id = ?
|
||
ORDER BY t.created_at ASC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return []domain.Team{}, err
|
||
}
|
||
|
||
teams := make([]domain.Team, 0)
|
||
for rows.Next() {
|
||
var t domain.Team
|
||
if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil {
|
||
t.Members = []domain.TeamMember{}
|
||
teams = append(teams, t)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range teams {
|
||
members, _ := s.GetTeamMembers(teams[i].ID)
|
||
teams[i].Members = members
|
||
}
|
||
return teams, nil
|
||
}
|
||
|
||
func (s *Store) AddTeamMember(teamID, userID, role string) error {
|
||
if role == "" {
|
||
role = "member"
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
|
||
ON CONFLICT(team_id, user_id) DO UPDATE SET role = excluded.role`,
|
||
teamID, userID, role, domain.NowISO(),
|
||
)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) RemoveTeamMember(teamID, userID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT tm.team_id, tm.user_id, u.display_name, u.email, tm.role, tm.joined_at
|
||
FROM team_members tm
|
||
JOIN users u ON u.id = tm.user_id
|
||
WHERE tm.team_id = ?
|
||
ORDER BY tm.joined_at ASC`,
|
||
teamID,
|
||
)
|
||
if err != nil {
|
||
return []domain.TeamMember{}, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
members := make([]domain.TeamMember, 0)
|
||
for rows.Next() {
|
||
var m domain.TeamMember
|
||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
|
||
members = append(members, m)
|
||
}
|
||
}
|
||
return members, nil
|
||
}
|
||
|
||
// --- USER INVITES ---
|
||
|
||
func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDays int) (*domain.UserInvite, error) {
|
||
if role != "admin" && role != "team_admin" && role != "member" {
|
||
role = "member"
|
||
}
|
||
if validDays <= 0 || validDays > 365 {
|
||
validDays = 7
|
||
}
|
||
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
|
||
now := time.Now().UTC()
|
||
inv := &domain.UserInvite{
|
||
ID: domain.NewID("inv"),
|
||
Code: code,
|
||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||
Role: role,
|
||
TeamID: strings.TrimSpace(teamID),
|
||
CreatedBy: createdBy,
|
||
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
|
||
CreatedAt: now.Format(time.RFC3339),
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO user_invites (id, code, email, role, team_id, created_by, expires_at, used_by, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
|
||
inv.ID, inv.Code, inv.Email, inv.Role, inv.TeamID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return inv, nil
|
||
}
|
||
|
||
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT i.id, i.code, i.email, i.role, i.team_id, COALESCE(t.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
|
||
FROM user_invites i
|
||
LEFT JOIN teams t ON t.id = i.team_id
|
||
ORDER BY i.created_at DESC`,
|
||
)
|
||
if err != nil {
|
||
return []domain.UserInvite{}, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
invites := make([]domain.UserInvite, 0)
|
||
for rows.Next() {
|
||
var inv domain.UserInvite
|
||
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.TeamName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
|
||
invites = append(invites, inv)
|
||
}
|
||
}
|
||
return invites, nil
|
||
}
|
||
|
||
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
|
||
code = strings.ToUpper(strings.TrimSpace(code))
|
||
var inv domain.UserInvite
|
||
err := s.db.QueryRow(
|
||
`SELECT id, code, email, role, team_id, created_by, expires_at, used_by, created_at
|
||
FROM user_invites WHERE UPPER(code) = ?`,
|
||
code,
|
||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
|
||
if err != nil {
|
||
return nil, errors.New("Ungültiger Einladungscode")
|
||
}
|
||
if inv.UsedBy != "" {
|
||
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
|
||
}
|
||
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
|
||
return nil, errors.New("Dieser Einladungscode ist abgelaufen")
|
||
}
|
||
return &inv, nil
|
||
}
|
||
|
||
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
|
||
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) DeleteUserInvite(inviteID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
|
||
return err
|
||
}
|
||
|
||
// --- SYSTEM SETTINGS ---
|
||
|
||
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls bool) {
|
||
cur, err := s.GetSystemSettings()
|
||
if err != nil || cur == nil {
|
||
return
|
||
}
|
||
// Only populate DB from env if DB has no SMTP host configured yet and env provides one
|
||
if cur.SMTPHost == "" && strings.TrimSpace(host) != "" {
|
||
cur.SMTPHost = strings.TrimSpace(host)
|
||
if strings.TrimSpace(port) != "" {
|
||
cur.SMTPPort = strings.TrimSpace(port)
|
||
}
|
||
cur.SMTPUser = strings.TrimSpace(user)
|
||
cur.SMTPPass = pass
|
||
cur.SMTPFrom = strings.TrimSpace(from)
|
||
cur.SMTPTLS = tls
|
||
_ = s.UpdateSystemSettings(cur)
|
||
}
|
||
}
|
||
|
||
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||
settings := &domain.SystemSettings{
|
||
SharingMode: "teams_only",
|
||
AllowRegistration: false,
|
||
InstanceName: "MindSift",
|
||
SMTPPort: "587",
|
||
SMTPTLS: true,
|
||
}
|
||
|
||
rows, err := s.db.Query(`SELECT key, value FROM system_settings`)
|
||
if err != nil {
|
||
return settings, nil
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var k, v string
|
||
if err := rows.Scan(&k, &v); err == nil {
|
||
switch k {
|
||
case "sharing_mode":
|
||
settings.SharingMode = v
|
||
case "allow_registration":
|
||
settings.AllowRegistration = (v == "true" || v == "1")
|
||
case "instance_name":
|
||
settings.InstanceName = v
|
||
case "smtp_host":
|
||
settings.SMTPHost = v
|
||
case "smtp_port":
|
||
if v != "" {
|
||
settings.SMTPPort = v
|
||
}
|
||
case "smtp_user":
|
||
settings.SMTPUser = v
|
||
case "smtp_pass":
|
||
settings.SMTPPass = v
|
||
case "smtp_from":
|
||
settings.SMTPFrom = v
|
||
case "smtp_tls":
|
||
settings.SMTPTLS = (v != "false" && v != "0")
|
||
}
|
||
}
|
||
}
|
||
return settings, nil
|
||
}
|
||
|
||
func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||
regStr := "false"
|
||
if settings.AllowRegistration {
|
||
regStr = "true"
|
||
}
|
||
tlsStr := "false"
|
||
if settings.SMTPTLS {
|
||
tlsStr = "true"
|
||
}
|
||
if settings.SMTPPort == "" {
|
||
settings.SMTPPort = "587"
|
||
}
|
||
if settings.SharingMode == "" {
|
||
settings.SharingMode = "teams_only"
|
||
}
|
||
if settings.InstanceName == "" {
|
||
settings.InstanceName = "MindSift"
|
||
}
|
||
_, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES
|
||
('sharing_mode', ?),
|
||
('allow_registration', ?),
|
||
('instance_name', ?),
|
||
('smtp_host', ?),
|
||
('smtp_port', ?),
|
||
('smtp_user', ?),
|
||
('smtp_pass', ?),
|
||
('smtp_from', ?),
|
||
('smtp_tls', ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||
settings.SharingMode,
|
||
regStr,
|
||
settings.InstanceName,
|
||
strings.TrimSpace(settings.SMTPHost),
|
||
strings.TrimSpace(settings.SMTPPort),
|
||
strings.TrimSpace(settings.SMTPUser),
|
||
settings.SMTPPass,
|
||
strings.TrimSpace(settings.SMTPFrom),
|
||
tlsStr,
|
||
)
|
||
return err
|
||
}
|
||
|
||
// --- ALARMS ---
|
||
|
||
type DueTaskAlarm struct {
|
||
TaskID string
|
||
UserID string
|
||
Title string
|
||
Notes string
|
||
DueDate string
|
||
Priority string
|
||
UserEmail string
|
||
AlarmChannels []string
|
||
AlarmEmail string
|
||
DiscordWebhookURL string
|
||
CustomWebhookURL string
|
||
NotifyBeforeMinutes int
|
||
}
|
||
|
||
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority,
|
||
u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes
|
||
FROM thoughts t
|
||
JOIN users u ON u.id = t.user_id
|
||
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
|
||
AND t.is_completed = 0
|
||
AND t.alarm_sent = 0
|
||
AND t.due_date != ''
|
||
AND u.is_active = 1`,
|
||
)
|
||
if err != nil {
|
||
return []DueTaskAlarm{}, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
alarms := make([]DueTaskAlarm, 0)
|
||
for rows.Next() {
|
||
var a DueTaskAlarm
|
||
var channelsRaw string
|
||
if err := rows.Scan(
|
||
&a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority,
|
||
&a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes,
|
||
); err == nil {
|
||
a.AlarmChannels = parseAlarmChannels(channelsRaw)
|
||
if a.AlarmEmail == "" {
|
||
a.AlarmEmail = a.UserEmail
|
||
}
|
||
alarms = append(alarms, a)
|
||
}
|
||
}
|
||
return alarms, nil
|
||
}
|
||
|
||
func (s *Store) MarkTaskAlarmSent(taskID string) error {
|
||
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
|
||
return err
|
||
}
|
||
|