MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 38s
2236 lines
66 KiB
Go
2236 lines
66 KiB
Go
package storage
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"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,
|
||
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 '',
|
||
title TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '📋',
|
||
color TEXT NOT NULL DEFAULT '#2563eb',
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS spaces (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '🏠',
|
||
description TEXT NOT NULL DEFAULT '',
|
||
invite_code TEXT UNIQUE NOT NULL,
|
||
is_personal INTEGER NOT NULL DEFAULT 0,
|
||
owner_id TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS space_members (
|
||
space_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'editor',
|
||
joined_at TEXT NOT NULL,
|
||
PRIMARY KEY (space_id, user_id),
|
||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS organizations (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
owner_id TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS teams (
|
||
id TEXT PRIMARY KEY,
|
||
org_id TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(org_id) REFERENCES organizations(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS team_members (
|
||
team_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'editor',
|
||
joined_at TEXT NOT NULL,
|
||
PRIMARY KEY (team_id, user_id),
|
||
FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS documents (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
space_id TEXT DEFAULT '',
|
||
title TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '📄',
|
||
content TEXT NOT NULL DEFAULT '',
|
||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||
tags TEXT NOT NULL DEFAULT '[]',
|
||
folder_path TEXT NOT NULL DEFAULT '',
|
||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS lists (
|
||
id TEXT PRIMARY KEY,
|
||
space_id TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
icon TEXT NOT NULL DEFAULT '🛒',
|
||
list_type TEXT NOT NULL DEFAULT 'shopping',
|
||
updated_at TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC);
|
||
|
||
CREATE TABLE IF NOT EXISTS list_items (
|
||
id TEXT PRIMARY KEY,
|
||
list_id TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
category TEXT NOT NULL DEFAULT '',
|
||
quantity TEXT NOT NULL DEFAULT '',
|
||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||
completed_by TEXT NOT NULL DEFAULT '',
|
||
completed_by_name TEXT NOT NULL DEFAULT '',
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
completed_at TEXT NOT NULL DEFAULT '',
|
||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
|
||
|
||
CREATE TABLE IF NOT EXISTS api_keys (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
key_prefix TEXT NOT NULL,
|
||
key_hash TEXT UNIQUE NOT NULL,
|
||
scopes TEXT NOT NULL DEFAULT 'ingest,read',
|
||
last_used_at TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS system_settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
`
|
||
if _, err := s.db.Exec(schema); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Idempotent column migrations for existing databases
|
||
columnMigrations := []string{
|
||
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
|
||
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
||
"ALTER TABLE users ADD COLUMN 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 '';",
|
||
}
|
||
for _, q := range columnMigrations {
|
||
_, _ = s.db.Exec(q)
|
||
}
|
||
|
||
// Remove legacy auto-seeded default user ('admin@mindsift.local') so First-Boot Setup triggers cleanly
|
||
var legacyUserID string
|
||
if err := s.db.QueryRow(`SELECT id FROM users WHERE email = 'admin@mindsift.local'`).Scan(&legacyUserID); err == nil && legacyUserID != "" {
|
||
_, _ = s.db.Exec(`DELETE FROM thoughts WHERE user_id = ?`, legacyUserID)
|
||
_, _ = s.db.Exec(`DELETE FROM documents WHERE user_id = ?`, legacyUserID)
|
||
_, _ = s.db.Exec(`DELETE FROM spaces WHERE owner_id = ?`, legacyUserID)
|
||
_, _ = s.db.Exec(`DELETE FROM task_lists WHERE user_id = ?`, legacyUserID)
|
||
_, _ = s.db.Exec(`DELETE FROM users WHERE id = ?`, legacyUserID)
|
||
}
|
||
|
||
// 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')`)
|
||
|
||
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, 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.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) {
|
||
if role != "admin" && role != "team_admin" && role != "member" {
|
||
role = "member"
|
||
}
|
||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||
u := &domain.User{
|
||
ID: domain.NewID("usr"),
|
||
Email: cleanEmail,
|
||
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, 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.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) {
|
||
row := s.db.QueryRow(
|
||
`SELECT `+userSelectCols+` FROM users WHERE email = ?`,
|
||
strings.ToLower(strings.TrimSpace(email)),
|
||
)
|
||
return scanUser(row)
|
||
}
|
||
|
||
func (s *Store) GetUserByID(id string) (*domain.User, error) {
|
||
row := s.db.QueryRow(
|
||
`SELECT `+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: users sharing at least one team or organization 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 = ?
|
||
)`,
|
||
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) 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 (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`,
|
||
thoughtID, userID, userID,
|
||
)
|
||
return scanThought(row)
|
||
}
|
||
|
||
func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) {
|
||
if limit <= 0 || limit > 500 {
|
||
limit = 200
|
||
}
|
||
var rows *sql.Rows
|
||
var err error
|
||
|
||
if statusFilter != "" && statusFilter != "all" {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, 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 (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
|
||
AND task_list_id = ?
|
||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||
userID, userID, taskListID,
|
||
)
|
||
} else {
|
||
rows, err = s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks
|
||
FROM thoughts
|
||
WHERE (user_id = ? OR task_list_id IN (SELECT id FROM task_lists WHERE space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))
|
||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||
ORDER BY is_completed ASC, CASE WHEN due_date = '' THEN 1 ELSE 0 END, due_date ASC, created_at DESC`,
|
||
userID, userID,
|
||
)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
result := make([]domain.Thought, 0)
|
||
for rows.Next() {
|
||
t, err := scanThought(rows)
|
||
if err == nil {
|
||
result = append(result, *t)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
return result, nil
|
||
}
|
||
|
||
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
|
||
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
|
||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
now := domain.NowISO()
|
||
newStatus := domain.StatusKept
|
||
convertedType := thought.ConvertedType
|
||
convertedRefID := thought.ConvertedRefID
|
||
if dueDate == "" {
|
||
dueDate = thought.DueDate
|
||
}
|
||
if taskListID == "" {
|
||
taskListID = thought.TaskListID
|
||
}
|
||
|
||
switch action {
|
||
case "discard":
|
||
newStatus = domain.StatusDiscarded
|
||
case "restore":
|
||
newStatus = domain.StatusInbox
|
||
case "keep":
|
||
newStatus = domain.StatusKept
|
||
if convertTo == "task" {
|
||
newStatus = domain.StatusConverted
|
||
convertedType = "task"
|
||
} else if convertTo == "document" {
|
||
newStatus = domain.StatusConverted
|
||
convertedType = "document"
|
||
title := thought.Content
|
||
if len(title) > 72 {
|
||
title = title[:72] + "…"
|
||
}
|
||
blocks := make([]domain.DocumentBlock, 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) == 0 {
|
||
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 = ? AND user_id = ?`,
|
||
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return thought, nil
|
||
}
|
||
|
||
func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ? AND user_id = ?`, thoughtID, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
||
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return res.RowsAffected()
|
||
}
|
||
|
||
// --- DOCUMENTS (WORKSPACE) ---
|
||
|
||
func (s *Store) saveDocumentToDisk(doc *domain.Document) {
|
||
if s.docDir == "" || doc == nil {
|
||
return
|
||
}
|
||
userDir := filepath.Join(s.docDir, doc.UserID)
|
||
if doc.FolderPath != "" {
|
||
userDir = filepath.Join(userDir, filepath.Clean(doc.FolderPath))
|
||
}
|
||
_ = os.MkdirAll(userDir, 0755)
|
||
|
||
var md strings.Builder
|
||
md.WriteString(fmt.Sprintf("# %s %s\n\n", doc.Icon, doc.Title))
|
||
if len(doc.Blocks) > 0 {
|
||
for _, b := range doc.Blocks {
|
||
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
|
||
}
|
||
s.saveDocumentToDisk(doc)
|
||
return doc, nil
|
||
}
|
||
|
||
func scanDocument(rows interface {
|
||
Scan(dest ...any) error
|
||
}) (*domain.Document, error) {
|
||
d := &domain.Document{}
|
||
var blocksStr, tagsStr string
|
||
var isPinned, isArchived int
|
||
err := rows.Scan(
|
||
&d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content,
|
||
&blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
d.IsPinned = isPinned != 0
|
||
d.IsArchived = isArchived != 0
|
||
d.Blocks = []domain.DocumentBlock{}
|
||
d.Tags = []string{}
|
||
_ = json.Unmarshal([]byte(blocksStr), &d.Blocks)
|
||
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
||
return d, nil
|
||
}
|
||
|
||
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at
|
||
FROM documents WHERE user_id = ? AND is_archived = 0
|
||
ORDER BY is_pinned DESC, updated_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
docs := make([]domain.Document, 0)
|
||
for rows.Next() {
|
||
d, err := scanDocument(rows)
|
||
if err == nil {
|
||
docs = append(docs, *d)
|
||
}
|
||
}
|
||
return docs, nil
|
||
}
|
||
|
||
func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) {
|
||
row := s.db.QueryRow(
|
||
`SELECT id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at
|
||
FROM documents WHERE id = ? AND user_id = ?`,
|
||
docID, userID,
|
||
)
|
||
return scanDocument(row)
|
||
}
|
||
|
||
func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) {
|
||
doc, err := s.GetDocumentByID(userID, docID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if title != nil {
|
||
doc.Title = strings.TrimSpace(*title)
|
||
}
|
||
if icon != nil && *icon != "" {
|
||
doc.Icon = *icon
|
||
}
|
||
if folderPath != nil {
|
||
doc.FolderPath = *folderPath
|
||
}
|
||
if blocks != nil {
|
||
doc.Blocks = *blocks
|
||
var plainBuilder strings.Builder
|
||
for _, b := range doc.Blocks {
|
||
if b.Content != "" {
|
||
plainBuilder.WriteString(b.Content)
|
||
plainBuilder.WriteString("\n")
|
||
}
|
||
}
|
||
doc.Content = plainBuilder.String()
|
||
}
|
||
if tags != nil {
|
||
doc.Tags = *tags
|
||
}
|
||
if isPinned != nil {
|
||
doc.IsPinned = *isPinned
|
||
}
|
||
|
||
doc.UpdatedAt = domain.NowISO()
|
||
blocksJSON, _ := json.Marshal(doc.Blocks)
|
||
tagsJSON, _ := json.Marshal(doc.Tags)
|
||
pinnedInt := 0
|
||
if doc.IsPinned {
|
||
pinnedInt = 1
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, folder_path = ?, is_pinned = ?, updated_at = ?
|
||
WHERE id = ? AND user_id = ?`,
|
||
doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.UpdatedAt, docID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.saveDocumentToDisk(doc)
|
||
return doc, nil
|
||
}
|
||
|
||
func (s *Store) DeleteDocument(userID, docID string) error {
|
||
doc, _ := s.GetDocumentByID(userID, docID)
|
||
if doc != nil {
|
||
s.removeDocumentFromDisk(doc)
|
||
}
|
||
_, err := s.db.Exec(`DELETE FROM documents WHERE id = ? AND user_id = ?`, docID, userID)
|
||
return err
|
||
}
|
||
|
||
// --- SPACES & REALTIME LISTS ---
|
||
|
||
func (s *Store) CreateSpace(ownerID, name, icon, description string, isPersonal bool) (*domain.Space, error) {
|
||
if strings.TrimSpace(name) == "" {
|
||
return nil, errors.New("space name required")
|
||
}
|
||
if icon == "" {
|
||
icon = "🏠"
|
||
}
|
||
now := domain.NowISO()
|
||
inviteCode := strings.ToUpper(domain.NewID("inv")[4:12])
|
||
personalInt := 0
|
||
if isPersonal {
|
||
personalInt = 1
|
||
}
|
||
|
||
sp := &domain.Space{
|
||
ID: domain.NewID("spc"),
|
||
Name: strings.TrimSpace(name),
|
||
Icon: icon,
|
||
Description: strings.TrimSpace(description),
|
||
InviteCode: inviteCode,
|
||
IsPersonal: isPersonal,
|
||
OwnerID: ownerID,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO spaces (id, name, icon, description, invite_code, is_personal, owner_id, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
sp.ID, sp.Name, sp.Icon, sp.Description, sp.InviteCode, personalInt, sp.OwnerID, sp.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, _ = s.db.Exec(
|
||
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'owner', ?)`,
|
||
sp.ID, ownerID, now,
|
||
)
|
||
return s.GetSpaceByID(sp.ID)
|
||
}
|
||
|
||
func (s *Store) JoinSpaceByInviteCode(userID, inviteCode string) (*domain.Space, error) {
|
||
inviteCode = strings.ToUpper(strings.TrimSpace(inviteCode))
|
||
var spaceID string
|
||
err := s.db.QueryRow(`SELECT id FROM spaces WHERE invite_code = ?`, inviteCode).Scan(&spaceID)
|
||
if err != nil {
|
||
return nil, errors.New("Ungültiger Einladungscode")
|
||
}
|
||
|
||
_, err = s.db.Exec(
|
||
`INSERT OR IGNORE INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, 'editor', ?)`,
|
||
spaceID, userID, domain.NowISO(),
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.GetSpaceByID(spaceID)
|
||
}
|
||
|
||
func (s *Store) GetSpaceByID(spaceID string) (*domain.Space, error) {
|
||
sp := &domain.Space{}
|
||
var personalInt int
|
||
err := s.db.QueryRow(
|
||
`SELECT id, name, icon, description, invite_code, is_personal, owner_id, created_at FROM spaces WHERE id = ?`,
|
||
spaceID,
|
||
).Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sp.IsPersonal = personalInt != 0
|
||
sp.Members, _ = s.GetSpaceMembers(spaceID)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaceID).Scan(&sp.ListsCount)
|
||
return sp, nil
|
||
}
|
||
|
||
func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT sm.space_id, sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
|
||
FROM space_members sm
|
||
JOIN users u ON u.id = sm.user_id
|
||
WHERE sm.space_id = ?
|
||
ORDER BY sm.joined_at ASC`,
|
||
spaceID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
members := make([]domain.SpaceMember, 0)
|
||
for rows.Next() {
|
||
var m domain.SpaceMember
|
||
if err := rows.Scan(&m.SpaceID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
|
||
members = append(members, m)
|
||
}
|
||
}
|
||
return members, nil
|
||
}
|
||
|
||
func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
|
||
FROM spaces s
|
||
JOIN space_members sm ON sm.space_id = s.id
|
||
WHERE sm.user_id = ?
|
||
ORDER BY s.created_at ASC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
spaces := make([]domain.Space, 0)
|
||
for rows.Next() {
|
||
var sp domain.Space
|
||
var personalInt int
|
||
if err := rows.Scan(&sp.ID, &sp.Name, &sp.Icon, &sp.Description, &sp.InviteCode, &personalInt, &sp.OwnerID, &sp.CreatedAt); err == nil {
|
||
sp.IsPersonal = personalInt != 0
|
||
spaces = append(spaces, sp)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range spaces {
|
||
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
|
||
}
|
||
return spaces, nil
|
||
}
|
||
|
||
func (s *Store) CreateList(spaceID, title, icon, listType string) (*domain.SharedList, error) {
|
||
if strings.TrimSpace(title) == "" {
|
||
return nil, errors.New("list title required")
|
||
}
|
||
if icon == "" {
|
||
icon = "🛒"
|
||
}
|
||
if listType == "" {
|
||
listType = "shopping"
|
||
}
|
||
now := domain.NowISO()
|
||
lst := &domain.SharedList{
|
||
ID: domain.NewID("lst"),
|
||
SpaceID: spaceID,
|
||
Title: strings.TrimSpace(title),
|
||
Icon: icon,
|
||
ListType: listType,
|
||
Items: []domain.ListItem{},
|
||
UpdatedAt: now,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO lists (id, space_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
lst.ID, lst.SpaceID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return lst, nil
|
||
}
|
||
|
||
func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at
|
||
FROM lists WHERE space_id = ? ORDER BY created_at ASC`,
|
||
spaceID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lists := make([]domain.SharedList, 0)
|
||
for rows.Next() {
|
||
var l domain.SharedList
|
||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||
lists = append(lists, l)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range lists {
|
||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||
}
|
||
return lists, nil
|
||
}
|
||
|
||
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at
|
||
FROM lists l
|
||
JOIN space_members sm ON sm.space_id = l.space_id
|
||
WHERE sm.user_id = ?
|
||
ORDER BY l.updated_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
lists := make([]domain.SharedList, 0)
|
||
for rows.Next() {
|
||
var l domain.SharedList
|
||
if err := rows.Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt); err == nil {
|
||
lists = append(lists, l)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range lists {
|
||
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
||
}
|
||
return lists, nil
|
||
}
|
||
|
||
func (s *Store) GetListByID(listID string) (*domain.SharedList, error) {
|
||
var l domain.SharedList
|
||
err := s.db.QueryRow(
|
||
`SELECT id, space_id, title, icon, list_type, updated_at, created_at FROM lists WHERE id = ?`,
|
||
listID,
|
||
).Scan(&l.ID, &l.SpaceID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
l.Items, _ = s.GetListItems(l.ID)
|
||
return &l, nil
|
||
}
|
||
|
||
func (s *Store) DeleteList(listID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||
FROM list_items WHERE list_id = ?
|
||
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
|
||
listID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]domain.ListItem, 0)
|
||
for rows.Next() {
|
||
var it domain.ListItem
|
||
var doneInt int
|
||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil {
|
||
it.IsCompleted = doneInt != 0
|
||
items = append(items, it)
|
||
}
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int) (*domain.ListItem, error) {
|
||
content = strings.TrimSpace(content)
|
||
if content == "" {
|
||
return nil, errors.New("item content cannot be empty")
|
||
}
|
||
now := domain.NowISO()
|
||
it := &domain.ListItem{
|
||
ID: domain.NewID("itm"),
|
||
ListID: listID,
|
||
Content: content,
|
||
Category: strings.TrimSpace(category),
|
||
Quantity: strings.TrimSpace(quantity),
|
||
IsCompleted: false,
|
||
SortOrder: sortOrder,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at)
|
||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
|
||
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, listID)
|
||
return it, nil
|
||
}
|
||
|
||
func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName string) (*domain.ListItem, error) {
|
||
now := domain.NowISO()
|
||
doneInt := 0
|
||
completedAt := ""
|
||
completedBy := ""
|
||
completedByName := ""
|
||
if isCompleted {
|
||
doneInt = 1
|
||
completedAt = now
|
||
completedBy = userID
|
||
completedByName = userName
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`UPDATE list_items SET is_completed = ?, completed_by = ?, completed_by_name = ?, completed_at = ? WHERE id = ?`,
|
||
doneInt, completedBy, completedByName, completedAt, itemID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var it domain.ListItem
|
||
var dInt int
|
||
err = s.db.QueryRow(
|
||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||
FROM list_items WHERE id = ?`,
|
||
itemID,
|
||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
it.IsCompleted = dInt != 0
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||
return &it, nil
|
||
}
|
||
|
||
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||
var listID string
|
||
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
||
_, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, itemID)
|
||
if listID != "" {
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||
}
|
||
return listID, err
|
||
}
|
||
|
||
func (s *Store) ClearCompletedListItems(listID string) (int64, error) {
|
||
res, err := s.db.Exec(`DELETE FROM list_items WHERE list_id = ? AND is_completed = 1`, listID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||
return res.RowsAffected()
|
||
}
|
||
|
||
// --- API KEYS & WEBHOOKS ---
|
||
|
||
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
||
if strings.TrimSpace(name) == "" {
|
||
name = "Smarthome / Webhook Key"
|
||
}
|
||
rawKey, prefix, hash := auth.GenerateAPIKey()
|
||
k := &domain.APIKey{
|
||
ID: domain.NewID("key"),
|
||
UserID: userID,
|
||
Name: strings.TrimSpace(name),
|
||
KeyPrefix: prefix,
|
||
KeyHash: hash,
|
||
RawKey: rawKey,
|
||
Scopes: "ingest,read",
|
||
CreatedAt: domain.NowISO(),
|
||
}
|
||
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return k, nil
|
||
}
|
||
|
||
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
|
||
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
keys := make([]domain.APIKey, 0)
|
||
for rows.Next() {
|
||
var k domain.APIKey
|
||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
|
||
keys = append(keys, k)
|
||
}
|
||
}
|
||
return keys, nil
|
||
}
|
||
|
||
func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
|
||
return err
|
||
}
|
||
|
||
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||
var keyID, userID string
|
||
err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID)
|
||
if err != nil {
|
||
return nil, errors.New("invalid API key")
|
||
}
|
||
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
||
return s.GetUserByID(userID)
|
||
}
|
||
|
||
// --- STATS, SEARCH & EXPORT ---
|
||
|
||
func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) {
|
||
st := &domain.DashboardStats{}
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 0`, userID).Scan(&st.TasksOpenCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 1`, userID).Scan(&st.TasksDoneCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
|
||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists l JOIN space_members sm ON sm.space_id = l.space_id WHERE sm.user_id = ?`, userID).Scan(&st.ListsCount)
|
||
return st, nil
|
||
}
|
||
|
||
func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) {
|
||
q := "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||
|
||
tRows, err := s.db.Query(
|
||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, 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 id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at
|
||
FROM documents WHERE user_id = ? AND (LOWER(title) LIKE ? OR LOWER(content) LIKE ?) ORDER BY updated_at DESC LIMIT 20`,
|
||
userID, q, q,
|
||
)
|
||
docs := make([]domain.Document, 0)
|
||
if err == nil {
|
||
for dRows.Next() {
|
||
if d, err := scanDocument(dRows); err == nil {
|
||
docs = append(docs, *d)
|
||
}
|
||
}
|
||
_ = dRows.Close()
|
||
}
|
||
|
||
return thoughts, docs, nil
|
||
}
|
||
|
||
func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||
user, err := s.GetUserByID(userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
thoughts, _ := s.GetThoughts(userID, "all", 5000)
|
||
docs, _ := s.GetDocuments(userID)
|
||
spaces, _ := s.GetSpacesForUser(userID)
|
||
lists, _ := s.GetAllListsForUser(userID)
|
||
taskLists, _ := s.GetTaskLists(userID)
|
||
|
||
return &domain.FullExport{
|
||
ExportedAt: domain.NowISO(),
|
||
Version: "1.0.0",
|
||
User: *user,
|
||
Thoughts: thoughts,
|
||
Documents: docs,
|
||
Spaces: spaces,
|
||
Lists: lists,
|
||
TaskLists: taskLists,
|
||
}, nil
|
||
}
|
||
|
||
// --- TASK LISTS ---
|
||
|
||
func (s *Store) CreateTaskList(userID, spaceID, title, icon, color string) (*domain.TaskList, error) {
|
||
if strings.TrimSpace(title) == "" {
|
||
title = "Meine Aufgaben"
|
||
}
|
||
if icon == "" {
|
||
icon = "📋"
|
||
}
|
||
if color == "" {
|
||
color = "#2563eb"
|
||
}
|
||
now := domain.NowISO()
|
||
tl := &domain.TaskList{
|
||
ID: domain.NewID("tkl"),
|
||
UserID: userID,
|
||
SpaceID: spaceID,
|
||
Title: strings.TrimSpace(title),
|
||
Icon: icon,
|
||
Color: color,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
_, err := s.db.Exec(
|
||
`INSERT INTO task_lists (id, user_id, space_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return tl, nil
|
||
}
|
||
|
||
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||
rows, err := s.db.Query(
|
||
`SELECT id, user_id, space_id, title, icon, color, created_at, updated_at
|
||
FROM task_lists
|
||
WHERE user_id = ? OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||
ORDER BY updated_at DESC`,
|
||
userID, userID,
|
||
)
|
||
if err != nil {
|
||
return []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.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||
list = append(list, tl)
|
||
}
|
||
}
|
||
_ = rows.Close()
|
||
|
||
for i := range list {
|
||
list[i].Members = make([]domain.SpaceMember, 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].SpaceID != "" {
|
||
_ = s.db.QueryRow(`SELECT name FROM spaces WHERE id = ?`, list[i].SpaceID).Scan(&list[i].SpaceName)
|
||
if members, err := s.GetSpaceMembers(list[i].SpaceID); err == nil && members != nil {
|
||
list[i].Members = members
|
||
}
|
||
}
|
||
}
|
||
return list, nil
|
||
}
|
||
|
||
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
|
||
var tl domain.TaskList
|
||
err := s.db.QueryRow(
|
||
`SELECT id, user_id, space_id, title, icon, color, created_at, updated_at
|
||
FROM task_lists WHERE id = ? AND (user_id = ? OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?)))`,
|
||
listID, userID, userID,
|
||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tl.Members = make([]domain.SpaceMember, 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'))`, tl.ID).Scan(&tl.TasksCount)
|
||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
|
||
if tl.SpaceID != "" {
|
||
_ = s.db.QueryRow(`SELECT name FROM spaces WHERE id = ?`, tl.SpaceID).Scan(&tl.SpaceName)
|
||
if members, err := s.GetSpaceMembers(tl.SpaceID); err == nil && members != nil {
|
||
tl.Members = members
|
||
}
|
||
}
|
||
return &tl, nil
|
||
}
|
||
|
||
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string) (*domain.TaskList, error) {
|
||
tl, err := s.GetTaskListByID(userID, listID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if title != nil {
|
||
tl.Title = strings.TrimSpace(*title)
|
||
}
|
||
if icon != nil && *icon != "" {
|
||
tl.Icon = *icon
|
||
}
|
||
if color != nil && *color != "" {
|
||
tl.Color = *color
|
||
}
|
||
if spaceID != nil {
|
||
tl.SpaceID = *spaceID
|
||
}
|
||
tl.UpdatedAt = domain.NowISO()
|
||
_, err = s.db.Exec(
|
||
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
|
||
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.UpdatedAt, listID, userID,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return 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)
|
||
_, 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) 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
|
||
}
|
||
|