"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL are configured. The request always gets the same answer and sends the mail in the background (at most every 2 minutes per account). The link holds a random token valid once for one hour; only its SHA-256 is stored. Setting the new password invalidates all open links and ends every session. The app removes the token from the address bar and offers a way back for expired links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5231 lines
167 KiB
Go
5231 lines
167 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
initMu sync.Mutex
|
|
|
|
// OnTaskCreated is told about tasks the store creates by itself (next occurrence of a repeating
|
|
// task), so the server can push them to connected clients.
|
|
OnTaskCreated func(*domain.Thought)
|
|
}
|
|
|
|
func NewStore(dbPath string) (*Store, error) {
|
|
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
|
|
dir := filepath.Dir(dbPath)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create data directory %s: %w", dir, err)
|
|
}
|
|
}
|
|
|
|
dsn := dbPath
|
|
if !strings.Contains(dsn, "?") {
|
|
dsn += "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
|
|
}
|
|
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
|
|
}
|
|
|
|
db.SetMaxOpenConns(8)
|
|
db.SetMaxIdleConns(4)
|
|
|
|
store := &Store{db: db}
|
|
if err := store.migrate(); err != nil {
|
|
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
|
}
|
|
|
|
return store, nil
|
|
}
|
|
|
|
func (s *Store) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
// schemaVersion is stored in PRAGMA user_version so later releases can tell which migrations a database
|
|
// already has. (Version 2 once wiped older databases on start; all installations are past that now.)
|
|
const schemaVersion = 2
|
|
|
|
// dropAllTables removes every table; used by the demo mode reset.
|
|
func (s *Store) dropAllTables() error {
|
|
conn, err := s.db.Conn(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close()
|
|
ctx := context.Background()
|
|
rows, err := conn.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var tables []string
|
|
for rows.Next() {
|
|
var name string
|
|
if rows.Scan(&name) == nil {
|
|
tables = append(tables, name)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
|
return err
|
|
}
|
|
for _, t := range tables {
|
|
if _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS "`+strings.ReplaceAll(t, `"`, `""`)+`"`); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
|
|
return err
|
|
}
|
|
|
|
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,
|
|
token_version 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,
|
|
avatar_url TEXT NOT NULL DEFAULT '',
|
|
organization_id TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
|
|
|
|
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',
|
|
organization_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',
|
|
columns TEXT NOT NULL DEFAULT '[]',
|
|
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);
|
|
|
|
-- Organizations are defined by server admins and decide who can see whom (sharing_mode = organizations)
|
|
CREATE TABLE IF NOT EXISTS organizations (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
-- Teams are the user-managed groups ("Gruppen"); org_id is the creator's organization
|
|
CREATE TABLE IF NOT EXISTS teams (
|
|
id TEXT PRIMARY KEY,
|
|
org_id TEXT NOT NULL DEFAULT '',
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS team_members (
|
|
team_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'member',
|
|
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 DEFAULT '',
|
|
space_id TEXT DEFAULT '',
|
|
title TEXT NOT NULL DEFAULT '',
|
|
folder_id TEXT NOT NULL DEFAULT '',
|
|
markdown_cache TEXT NOT NULL DEFAULT '',
|
|
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 DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
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 blocks (
|
|
id TEXT PRIMARY KEY,
|
|
document_id TEXT NOT NULL,
|
|
content TEXT NOT NULL DEFAULT '',
|
|
level INT NOT NULL DEFAULT 0,
|
|
position REAL NOT NULL DEFAULT 0,
|
|
type TEXT NOT NULL DEFAULT 'bullet',
|
|
checked INTEGER NOT NULL DEFAULT 0,
|
|
collapsed INTEGER NOT NULL DEFAULT 0,
|
|
lang TEXT NOT NULL DEFAULT '',
|
|
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);
|
|
|
|
CREATE TABLE IF NOT EXISTS block_tags (
|
|
tag TEXT NOT NULL,
|
|
document_id TEXT NOT NULL,
|
|
block_id TEXT NOT NULL,
|
|
PRIMARY KEY(tag, block_id),
|
|
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
|
FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);
|
|
CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);
|
|
|
|
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 tag_pages (
|
|
user_id TEXT NOT NULL,
|
|
tag TEXT NOT NULL,
|
|
content TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY(user_id, tag)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS lists (
|
|
id TEXT PRIMARY KEY,
|
|
user_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(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS list_shares (
|
|
id TEXT PRIMARY KEY,
|
|
list_id TEXT NOT NULL,
|
|
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
|
target_id TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')),
|
|
created_by TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE(list_id, target_type, target_id),
|
|
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE,
|
|
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);
|
|
CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);
|
|
|
|
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
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS revoked_tokens (
|
|
token_hash TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
revoked_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
|
|
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
|
|
|
|
-- Custom CSS themes uploaded or pasted by a user (stored in the volume, survive updates)
|
|
CREATE TABLE IF NOT EXISTS user_themes (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
css_content TEXT NOT NULL,
|
|
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_user_themes_user ON user_themes(user_id);
|
|
`
|
|
if _, err := s.db.Exec(schema); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Idempotent column migrations for existing databases
|
|
columnMigrations := []string{
|
|
"ALTER TABLE documents ADD COLUMN archived_at TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE documents ADD COLUMN archived_by TEXT NOT NULL DEFAULT '';",
|
|
"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 token_version 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 users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';",
|
|
|
|
"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 thoughts ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;",
|
|
"CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);",
|
|
|
|
"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 columns 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);",
|
|
"ALTER TABLE task_list_shares ADD COLUMN permission TEXT NOT NULL DEFAULT 'editor';",
|
|
|
|
"CREATE TABLE IF NOT EXISTS doc_team_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, team_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, team_id));",
|
|
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);",
|
|
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_id);",
|
|
|
|
"ALTER TABLE lists ADD COLUMN user_id TEXT NOT NULL DEFAULT '';",
|
|
"CREATE TABLE IF NOT EXISTS list_shares (id TEXT PRIMARY KEY, list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(list_id, target_type, target_id));",
|
|
"CREATE INDEX IF NOT EXISTS idx_list_shares_target ON list_shares(target_type, target_id);",
|
|
"CREATE INDEX IF NOT EXISTS idx_list_shares_list ON list_shares(list_id);",
|
|
|
|
// Workspace Shared Folders & User Folder Placements
|
|
"ALTER TABLE documents ADD COLUMN shared_folder_id TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE documents ADD COLUMN sub_folder_path TEXT NOT NULL DEFAULT '';",
|
|
"CREATE INDEX IF NOT EXISTS idx_documents_shared_folder ON documents(shared_folder_id);",
|
|
|
|
"CREATE TABLE IF NOT EXISTS workspace_shared_folders (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);",
|
|
"CREATE INDEX IF NOT EXISTS idx_workspace_shared_folders_owner ON workspace_shared_folders(owner_id);",
|
|
|
|
"CREATE TABLE IF NOT EXISTS workspace_folder_shares (id TEXT PRIMARY KEY, folder_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(folder_id, target_type, target_id));",
|
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_shares_folder ON workspace_folder_shares(folder_id);",
|
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_shares_target ON workspace_folder_shares(target_type, target_id);",
|
|
|
|
"CREATE TABLE IF NOT EXISTS workspace_folder_mounts (user_id TEXT NOT NULL, folder_id TEXT NOT NULL, mount_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, folder_id));",
|
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folder_mounts_user ON workspace_folder_mounts(user_id);",
|
|
|
|
"CREATE TABLE IF NOT EXISTS doc_user_folders (user_id TEXT NOT NULL, doc_id TEXT NOT NULL, folder_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, doc_id));",
|
|
"CREATE INDEX IF NOT EXISTS idx_doc_user_folders_user ON doc_user_folders(user_id);",
|
|
|
|
"CREATE TABLE IF NOT EXISTS workspace_folders (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, shared_folder_id TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, sub_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL);",
|
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_user ON workspace_folders(user_id);",
|
|
"CREATE INDEX IF NOT EXISTS idx_workspace_folders_sf ON workspace_folders(shared_folder_id);",
|
|
|
|
"ALTER TABLE documents ADD COLUMN folder_id TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE documents ADD COLUMN markdown_cache TEXT NOT NULL DEFAULT '';",
|
|
|
|
"CREATE TABLE IF NOT EXISTS blocks (id TEXT PRIMARY KEY, document_id TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', level INT NOT NULL DEFAULT 0, position REAL NOT NULL DEFAULT 0, type TEXT NOT NULL DEFAULT 'bullet', checked INTEGER NOT NULL DEFAULT 0, collapsed INTEGER NOT NULL DEFAULT 0, lang TEXT NOT NULL DEFAULT '', FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE);",
|
|
"CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);",
|
|
|
|
"CREATE TABLE IF NOT EXISTS block_tags (tag TEXT NOT NULL, document_id TEXT NOT NULL, block_id TEXT NOT NULL, PRIMARY KEY(tag, block_id), FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE, FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE);",
|
|
"CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);",
|
|
"CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);",
|
|
|
|
// Task assignments: who a task in a shared task list is assigned to
|
|
"CREATE TABLE IF NOT EXISTS task_assignees (task_id TEXT NOT NULL, user_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(task_id, user_id), FOREIGN KEY(task_id) REFERENCES thoughts(id) ON DELETE CASCADE);",
|
|
"CREATE INDEX IF NOT EXISTS idx_task_assignees_user ON task_assignees(user_id);",
|
|
|
|
// Who added an item to a (shopping) list
|
|
"ALTER TABLE list_items ADD COLUMN created_by TEXT NOT NULL DEFAULT '';",
|
|
|
|
// Repeating tasks: daily | weekly | monthly | yearly
|
|
"ALTER TABLE thoughts ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';",
|
|
"ALTER TABLE thoughts ADD COLUMN recurrence_spawned INTEGER NOT NULL DEFAULT 0;",
|
|
}
|
|
for _, q := range columnMigrations {
|
|
_, _ = s.db.Exec(q)
|
|
}
|
|
|
|
// Clean up legacy space and old document block/crdt tables
|
|
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
|
|
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
|
|
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
|
|
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`)
|
|
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`)
|
|
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_blocks`)
|
|
_, _ = s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`)
|
|
|
|
// 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', 'organizations'),
|
|
('allow_registration', 'false'),
|
|
('instance_name', 'MindSift'),
|
|
('smtp_host', ''),
|
|
('smtp_port', '587'),
|
|
('smtp_user', ''),
|
|
('smtp_pass', ''),
|
|
('smtp_from', ''),
|
|
('smtp_tls', 'true'),
|
|
('smtp_skip_verify', 'false'),
|
|
('allow_private_webhooks', 'false')`)
|
|
|
|
// Clean up legacy roles in team_members and users
|
|
_, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`)
|
|
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
|
|
_, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`)
|
|
|
|
// Block tree, operation log and idempotency tables for collaborative editing
|
|
s.migrateDocSync()
|
|
|
|
// Migrate workspace documents, blocks, folders, and strip legacy folder:* tags into pure DB tables
|
|
s.migrateWorkspaceToPureDB()
|
|
|
|
// Reindex block tags if table is newly added or empty
|
|
var tagCount int
|
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM block_tags`).Scan(&tagCount)
|
|
if tagCount == 0 {
|
|
_ = s.reindexAllDocBlockTags()
|
|
}
|
|
s.migrateAnchorTags()
|
|
s.migrateSearchFTS()
|
|
if err := s.migratePasswordReset(); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
|
|
return err
|
|
}
|
|
|
|
// 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, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, organization_id, 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, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
|
|
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.OrganizationID, &u.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.IsActive = isActiveInt != 0
|
|
if u.TokenVersion <= 0 {
|
|
u.TokenVersion = 1
|
|
}
|
|
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 != "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,
|
|
TokenVersion: 1,
|
|
AlarmChannels: []string{},
|
|
AlarmEmail: cleanEmail,
|
|
DiscordWebhookURL: "",
|
|
CustomWebhookURL: "",
|
|
NotifyBeforeMinutes: 30,
|
|
AvatarURL: "",
|
|
CreatedAt: domain.NowISO(),
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 1, 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)
|
|
adminCreated := false
|
|
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)
|
|
}
|
|
adminCreated = true
|
|
} 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 {
|
|
normalUser, 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)
|
|
}
|
|
|
|
// 3. Sample data for the admin, partly shared with the demo user (only on first start). Both accounts
|
|
// share an organization so they can see each other in the default sharing mode.
|
|
if adminCreated {
|
|
if org, err := s.CreateOrganization("Demo"); err == nil {
|
|
_ = s.SetUserOrganization(adminUser.ID, org.ID)
|
|
_ = s.SetUserOrganization(normalUser.ID, org.ID)
|
|
}
|
|
_ = s.SeedStarterContent(adminUser)
|
|
s.seedDemoShares(adminUser, normalUser)
|
|
}
|
|
|
|
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) 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, nil)
|
|
}
|
|
|
|
func (s *Store) UpdateUserFull(
|
|
userID string,
|
|
displayName, email, role *string,
|
|
isActive *bool,
|
|
alarmChannels *[]string,
|
|
alarmEmail, discordWebhookURL, customWebhookURL *string,
|
|
notifyBeforeMinutes *int,
|
|
avatarURL *string,
|
|
) (*domain.User, error) {
|
|
u, err := s.GetUserByID(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if displayName != nil {
|
|
u.DisplayName = strings.TrimSpace(*displayName)
|
|
}
|
|
if email != nil {
|
|
u.Email = strings.ToLower(strings.TrimSpace(*email))
|
|
}
|
|
if role != nil && (*role == "admin" || *role == "member") {
|
|
u.Role = *role
|
|
}
|
|
if isActive != nil {
|
|
u.IsActive = *isActive
|
|
}
|
|
if 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
|
|
}
|
|
if avatarURL != nil {
|
|
u.AvatarURL = strings.TrimSpace(*avatarURL)
|
|
}
|
|
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
|
|
u.TokenVersion++
|
|
}
|
|
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 = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
|
|
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, 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 = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteUser(userID string) error {
|
|
// Remove owned data properly first (other people's tasks in the user's lists stay theirs)
|
|
if err := s.PurgeUserData(userID); err != nil {
|
|
return err
|
|
}
|
|
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
|
|
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
|
|
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
|
// Shares pointing at the deleted account are no longer visible to anyone
|
|
s.pruneSharingNow()
|
|
return err
|
|
}
|
|
|
|
// RevokeToken adds an individual JWT to the blacklist until its expiration date
|
|
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
|
|
hash := auth.HashAPIKey(tokenStr)
|
|
_, err := s.db.Exec(
|
|
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
|
|
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
|
|
)
|
|
// Idempotently prune old expired tokens
|
|
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
|
|
return err
|
|
}
|
|
|
|
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
|
|
func (s *Store) IsTokenRevoked(tokenStr string) bool {
|
|
hash := auth.HashAPIKey(tokenStr)
|
|
var count int
|
|
err := s.db.QueryRow(
|
|
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
|
|
hash, time.Now().UTC().Format(time.RFC3339),
|
|
).Scan(&count)
|
|
return err == nil && count > 0
|
|
}
|
|
|
|
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
|
|
func (s *Store) RevokeAllUserTokens(userID string) error {
|
|
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 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)
|
|
dueDate = domain.NormalizeDue(dueDate)
|
|
if content == "" {
|
|
return nil, errors.New("content cannot be empty")
|
|
}
|
|
if priority == "" {
|
|
priority = "normal"
|
|
}
|
|
if source == "" {
|
|
source = "manual"
|
|
}
|
|
if kanbanStatus == "" {
|
|
kanbanStatus = "todo"
|
|
}
|
|
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tags := domain.ExtractTags(content)
|
|
if strings.EqualFold(source, "voice") {
|
|
hasDiktat := false
|
|
for _, tag := range tags {
|
|
if strings.EqualFold(tag, "diktat") {
|
|
hasDiktat = true
|
|
break
|
|
}
|
|
}
|
|
if !hasDiktat {
|
|
tags = append(tags, "diktat")
|
|
}
|
|
}
|
|
if strings.EqualFold(source, "webhook") {
|
|
hasWebhook := false
|
|
for _, tag := range tags {
|
|
if strings.EqualFold(tag, "webhook") {
|
|
hasWebhook = true
|
|
break
|
|
}
|
|
}
|
|
if !hasWebhook {
|
|
tags = append(tags, "webhook")
|
|
}
|
|
}
|
|
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, sortOrder 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, &sortOrder, &t.Recurrence,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.SortOrder = sortOrder
|
|
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)
|
|
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
|
|
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
|
|
t.DocID = parts[0]
|
|
t.BlockID = parts[1]
|
|
}
|
|
t.Assignees = []string{}
|
|
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 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 = ?))
|
|
)`
|
|
}
|
|
|
|
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, sort_order, recurrence
|
|
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
|
thoughtID, userID, userID, userID, userID,
|
|
)
|
|
t, err := scanThought(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Assignees = s.getTaskAssignees(t.ID)
|
|
return t, nil
|
|
}
|
|
|
|
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, sort_order, recurrence
|
|
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, sort_order ASC, 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, sort_order, recurrence
|
|
FROM thoughts WHERE user_id = ?
|
|
ORDER BY sort_order ASC, 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, sort_order, recurrence
|
|
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, sort_order ASC, created_at DESC`,
|
|
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, sort_order, recurrence
|
|
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, sort_order ASC, created_at DESC`,
|
|
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()
|
|
s.attachTaskAssignees(result)
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
|
|
for _, it := range items {
|
|
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
|
|
isCompleted := 0
|
|
if *it.KanbanStatus == "done" {
|
|
isCompleted = 1
|
|
var was int
|
|
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
|
|
if was == 0 {
|
|
completed = append(completed, it.ID)
|
|
}
|
|
}
|
|
_, err = tx.Exec(
|
|
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
|
|
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`,
|
|
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID,
|
|
)
|
|
} else {
|
|
_, err = tx.Exec(
|
|
`UPDATE thoughts SET sort_order = ? WHERE id = ?`,
|
|
it.SortOrder, it.ID,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
for _, id := range completed {
|
|
s.spawnNextOccurrence(id)
|
|
}
|
|
return 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
|
|
dueDate = domain.NormalizeDue(dueDate)
|
|
if dueDate == "" {
|
|
dueDate = thought.DueDate
|
|
}
|
|
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
|
return nil, err
|
|
}
|
|
if taskListID == "" {
|
|
taskListID = thought.TaskListID
|
|
} else if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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)
|
|
|
|
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
|
|
blocks = append(blocks, domain.DocumentBlock{
|
|
ID: domain.NewID("blk"),
|
|
Type: "todo",
|
|
Content: thought.Content,
|
|
Checked: thought.IsCompleted,
|
|
Level: 0,
|
|
})
|
|
|
|
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
|
|
if strings.TrimSpace(thought.Notes) != "" {
|
|
blocks = append(blocks, domain.DocumentBlock{
|
|
ID: domain.NewID("blk"),
|
|
Type: "bullet",
|
|
Content: strings.TrimSpace(thought.Notes),
|
|
Level: 1,
|
|
})
|
|
}
|
|
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: 1,
|
|
})
|
|
}
|
|
}
|
|
var metaItems []string
|
|
if thought.DueDate != "" {
|
|
metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate))
|
|
}
|
|
if thought.Priority != "" && thought.Priority != "normal" {
|
|
metaItems = append(metaItems, "Priorität: "+thought.Priority)
|
|
}
|
|
if len(metaItems) > 0 {
|
|
blocks = append(blocks, domain.DocumentBlock{
|
|
ID: domain.NewID("blk"),
|
|
Type: "bullet",
|
|
Content: strings.Join(metaItems, " • "),
|
|
Level: 1,
|
|
})
|
|
}
|
|
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, userID)
|
|
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 = ?,
|
|
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END
|
|
WHERE id = ? AND user_id = ?`,
|
|
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if taskListID != thought.TaskListID {
|
|
s.PruneTaskAssignees()
|
|
}
|
|
|
|
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, tags *[]string) (*domain.Thought, error) {
|
|
thought, err := s.GetThoughtByID(userID, thoughtID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
wasCompleted := thought.IsCompleted
|
|
oldDue := thought.DueDate
|
|
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
|
|
return nil, err
|
|
}
|
|
if taskListID != nil && *taskListID != thought.TaskListID {
|
|
if err := s.ensureTaskListWritable(userID, *taskListID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if content != nil {
|
|
thought.Content = strings.TrimSpace(*content)
|
|
thought.Tags = domain.ExtractTags(thought.Content)
|
|
}
|
|
if tags != nil {
|
|
thought.Tags = *tags
|
|
}
|
|
if isCompleted != nil {
|
|
thought.IsCompleted = *isCompleted
|
|
if *isCompleted {
|
|
thought.KanbanStatus = "done"
|
|
} else if thought.KanbanStatus == "done" {
|
|
thought.KanbanStatus = "todo"
|
|
}
|
|
}
|
|
if dueDate != nil {
|
|
thought.DueDate = domain.NormalizeDue(*dueDate)
|
|
}
|
|
if priority != nil {
|
|
p := strings.ToLower(strings.TrimSpace(*priority))
|
|
if p == "" || p == "none" {
|
|
p = "normal"
|
|
}
|
|
thought.Priority = p
|
|
}
|
|
if taskListID != nil {
|
|
thought.TaskListID = *taskListID
|
|
}
|
|
if kanbanStatus != nil && *kanbanStatus != "" {
|
|
thought.KanbanStatus = *kanbanStatus
|
|
if isCompleted == nil {
|
|
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
|
|
}
|
|
// A new due date or a reopened task gets its alarm again
|
|
if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) {
|
|
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID)
|
|
thought.AlarmSent = false
|
|
}
|
|
if taskListID != nil {
|
|
s.PruneTaskAssignees()
|
|
thought.Assignees = s.getTaskAssignees(thoughtID)
|
|
}
|
|
if !wasCompleted && thought.IsCompleted {
|
|
s.spawnNextOccurrence(thoughtID)
|
|
}
|
|
return thought, nil
|
|
}
|
|
|
|
func (s *Store) DeleteThought(userID, thoughtID string) error {
|
|
thought, err := s.GetThoughtByID(userID, thoughtID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.ensureTaskListWritable(userID, thought.TaskListID); 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 - PURE DATABASE ARCHITECTURE) ---
|
|
|
|
func stripFolderTags(tags []string) []string {
|
|
if len(tags) == 0 {
|
|
return []string{}
|
|
}
|
|
cleaned := make([]string, 0, len(tags))
|
|
for _, t := range tags {
|
|
if !strings.HasPrefix(strings.TrimSpace(t), "folder:") {
|
|
cleaned = append(cleaned, t)
|
|
}
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func (s *Store) migrateWorkspaceToPureDB() {
|
|
now := domain.NowISO()
|
|
|
|
// 1. Migrate documents.blocks_json into document_blocks for any doc without rows in document_blocks
|
|
rows, err := s.db.Query(`
|
|
SELECT d.id, d.user_id, d.blocks_json, d.tags, d.folder_path, d.updated_at
|
|
FROM documents d
|
|
`)
|
|
if err == nil {
|
|
type migDoc struct {
|
|
id, userID, blocksJSON, tagsJSON, folderPath, updatedAt string
|
|
}
|
|
var docsToMigrate []migDoc
|
|
for rows.Next() {
|
|
var md migDoc
|
|
if err := rows.Scan(&md.id, &md.userID, &md.blocksJSON, &md.tagsJSON, &md.folderPath, &md.updatedAt); err == nil {
|
|
docsToMigrate = append(docsToMigrate, md)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
for _, md := range docsToMigrate {
|
|
var count int
|
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM blocks WHERE document_id = ?`, md.id).Scan(&count)
|
|
if count == 0 && md.blocksJSON != "" && md.blocksJSON != "[]" {
|
|
var blocks []domain.DocumentBlock
|
|
if err := json.Unmarshal([]byte(md.blocksJSON), &blocks); err == nil && len(blocks) > 0 {
|
|
ts := md.updatedAt
|
|
if ts == "" {
|
|
ts = now
|
|
}
|
|
s.replaceDocBlocksInDB(md.id, md.userID, blocks, ts)
|
|
}
|
|
}
|
|
|
|
// Strip legacy folder:* tags from documents.tags
|
|
if strings.Contains(md.tagsJSON, "folder:") {
|
|
var tags []string
|
|
if err := json.Unmarshal([]byte(md.tagsJSON), &tags); err == nil {
|
|
cleaned := stripFolderTags(tags)
|
|
cleanedBytes, _ := json.Marshal(cleaned)
|
|
_, _ = s.db.Exec(`UPDATE documents SET tags = ? WHERE id = ?`, string(cleanedBytes), md.id)
|
|
}
|
|
}
|
|
|
|
// Ensure personal folder exists in workspace_folders
|
|
if md.folderPath != "" {
|
|
var existsID string
|
|
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ?`, md.userID, md.folderPath).Scan(&existsID)
|
|
if err == sql.ErrNoRows {
|
|
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`,
|
|
domain.NewID("wfld"), md.userID, md.folderPath, now)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Sync all existing shared folders so all docs/subfolders in their owner's mount path are linked
|
|
sfRows, err := s.db.Query(`
|
|
SELECT f.id, f.owner_id, COALESCE(m.mount_path, f.name)
|
|
FROM workspace_shared_folders f
|
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id
|
|
`)
|
|
if err == nil {
|
|
type sfInfo struct{ id, ownerID, mountPath string }
|
|
var sfs []sfInfo
|
|
for sfRows.Next() {
|
|
var info sfInfo
|
|
if err := sfRows.Scan(&info.id, &info.ownerID, &info.mountPath); err == nil {
|
|
sfs = append(sfs, info)
|
|
}
|
|
}
|
|
_ = sfRows.Close()
|
|
for _, info := range sfs {
|
|
s.syncSharedFolderContents(info.ownerID, info.id, info.mountPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock {
|
|
if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 {
|
|
blocks := flatDocumentBlocks(docID, d)
|
|
if links := s.docTaskLinks(docID); len(links) > 0 {
|
|
for i := range blocks {
|
|
blocks[i].TaskID = links[blocks[i].ID]
|
|
}
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
// Very old notes only stored blocks_json; convert them once
|
|
blocks := make([]domain.DocumentBlock, 0)
|
|
if fallbackJSON != "" && fallbackJSON != "[]" {
|
|
_ = json.Unmarshal([]byte(fallbackJSON), &blocks)
|
|
if len(blocks) > 0 {
|
|
s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO())
|
|
if d, _, err := loadCollabDoc(s.db, docID); err == nil {
|
|
return flatDocumentBlocks(docID, d)
|
|
}
|
|
}
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID.
|
|
func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) {
|
|
userSet := make(map[string]bool)
|
|
|
|
var ownerID, sharedFolderID string
|
|
err := s.db.QueryRow(`SELECT user_id, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&ownerID, &sharedFolderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ownerID != "" {
|
|
userSet[ownerID] = true
|
|
}
|
|
|
|
// Direct user shares
|
|
uRows, err := s.db.Query(`SELECT user_id FROM doc_shares WHERE doc_id = ?`, docID)
|
|
if err == nil {
|
|
for uRows.Next() {
|
|
var uid string
|
|
if err := uRows.Scan(&uid); err == nil && uid != "" {
|
|
userSet[uid] = true
|
|
}
|
|
}
|
|
_ = uRows.Close()
|
|
}
|
|
|
|
// Team shares
|
|
tRows, err := s.db.Query(`
|
|
SELECT tm.user_id
|
|
FROM doc_team_shares dts
|
|
JOIN team_members tm ON tm.team_id = dts.team_id
|
|
WHERE dts.doc_id = ?
|
|
`, docID)
|
|
if err == nil {
|
|
for tRows.Next() {
|
|
var uid string
|
|
if err := tRows.Scan(&uid); err == nil && uid != "" {
|
|
userSet[uid] = true
|
|
}
|
|
}
|
|
_ = tRows.Close()
|
|
}
|
|
|
|
// Shared folder access
|
|
if sharedFolderID != "" {
|
|
sfUsers, err := s.GetAuthorizedSharedFolderUsers(sharedFolderID)
|
|
if err == nil {
|
|
for _, uid := range sfUsers {
|
|
userSet[uid] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
users := make([]string, 0, len(userSet))
|
|
for uid := range userSet {
|
|
users = append(users, uid)
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
// GetAuthorizedSharedFolderUsers returns all user IDs who have access to a shared folder.
|
|
func (s *Store) GetAuthorizedSharedFolderUsers(folderID string) ([]string, error) {
|
|
userSet := make(map[string]bool)
|
|
|
|
var ownerID string
|
|
if err := s.db.QueryRow(`SELECT owner_id FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&ownerID); err == nil && ownerID != "" {
|
|
userSet[ownerID] = true
|
|
}
|
|
|
|
rows, err := s.db.Query(`
|
|
SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ?
|
|
`, folderID)
|
|
if err == nil {
|
|
type shareTarget struct{ tType, tID string }
|
|
var targets []shareTarget
|
|
for rows.Next() {
|
|
var st shareTarget
|
|
if err := rows.Scan(&st.tType, &st.tID); err == nil {
|
|
targets = append(targets, st)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
for _, st := range targets {
|
|
if st.tType == "user" && st.tID != "" {
|
|
userSet[st.tID] = true
|
|
} else if st.tType == "team" && st.tID != "" {
|
|
mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, st.tID)
|
|
if err == nil {
|
|
for mRows.Next() {
|
|
var uid string
|
|
if err := mRows.Scan(&uid); err == nil && uid != "" {
|
|
userSet[uid] = true
|
|
}
|
|
}
|
|
_ = mRows.Close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
users := make([]string, 0, len(userSet))
|
|
for uid := range userSet {
|
|
users = append(users, uid)
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
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: ""},
|
|
}
|
|
}
|
|
tags = stripFolderTags(tags)
|
|
|
|
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
|
|
}
|
|
|
|
cleanFolder := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
|
if cleanFolder == "." {
|
|
cleanFolder = ""
|
|
}
|
|
|
|
mounts, _, _ := s.getUserFolderMounts(userID)
|
|
var sharedFolderID, subFolderPath string
|
|
if fID, sub, ok := matchMountPath(cleanFolder, mounts); ok {
|
|
sharedFolderID = fID
|
|
subFolderPath = sub
|
|
}
|
|
|
|
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: cleanFolder,
|
|
IsPinned: isPinned,
|
|
SharedFolderID: sharedFolderID,
|
|
SubFolderPath: subFolderPath,
|
|
IsSharedFolder: sharedFolderID != "",
|
|
Permission: "owner",
|
|
IsOwner: true,
|
|
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, shared_folder_id, sub_folder_path)
|
|
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, doc.SharedFolderID, doc.SubFolderPath,
|
|
)
|
|
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.replaceDocBlocksInDB(doc.ID, userID, doc.Blocks, now)
|
|
_ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks)
|
|
return doc, nil
|
|
}
|
|
|
|
// getUserFolderMounts returns map[folder_id]mount_path and map[folder_id]folder_name for all shared folders accessible to userID
|
|
func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[string]string, error) {
|
|
mounts := make(map[string]string)
|
|
names := make(map[string]string)
|
|
|
|
rows, err := s.db.Query(`
|
|
SELECT f.id, f.name, COALESCE(m.mount_path, f.name) as mount_path
|
|
FROM workspace_shared_folders f
|
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
|
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
|
(s.target_type = 'user' AND s.target_id = ?) OR
|
|
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)
|
|
WHERE f.owner_id = ? OR s.id IS NOT NULL
|
|
GROUP BY f.id
|
|
`, userID, userID, userID, userID)
|
|
if err != nil {
|
|
return mounts, names, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var fID, fName, mPath string
|
|
if err := rows.Scan(&fID, &fName, &mPath); err == nil {
|
|
names[fID] = fName
|
|
if mPath == "" {
|
|
mPath = fName
|
|
}
|
|
mounts[fID] = mPath
|
|
}
|
|
}
|
|
return mounts, names, nil
|
|
}
|
|
|
|
// matchMountPath checks if folderPath falls within any mounted shared folder.
|
|
// Returns (sharedFolderID, relativeSubPath, isMatch).
|
|
func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) {
|
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
|
|
if clean == "." {
|
|
clean = ""
|
|
}
|
|
if clean == "" {
|
|
return "", "", false
|
|
}
|
|
|
|
var bestFolderID string
|
|
var bestSubPath string
|
|
var longestMountLen int = -1
|
|
|
|
for fID, mPath := range mounts {
|
|
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mPath)))
|
|
if cleanMount == "." {
|
|
cleanMount = ""
|
|
}
|
|
if cleanMount == "" {
|
|
continue
|
|
}
|
|
|
|
if clean == cleanMount {
|
|
if len(cleanMount) > longestMountLen {
|
|
longestMountLen = len(cleanMount)
|
|
bestFolderID = fID
|
|
bestSubPath = ""
|
|
}
|
|
} else if strings.HasPrefix(clean, cleanMount+"/") {
|
|
sub := strings.TrimPrefix(clean, cleanMount+"/")
|
|
if len(cleanMount) > longestMountLen {
|
|
longestMountLen = len(cleanMount)
|
|
bestFolderID = fID
|
|
bestSubPath = sub
|
|
}
|
|
}
|
|
}
|
|
|
|
if longestMountLen >= 0 {
|
|
return bestFolderID, bestSubPath, true
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func resolveDocumentPath(userID string, doc *domain.Document, mounts map[string]string, names map[string]string, userFolders map[string]string) {
|
|
if doc.SharedFolderID != "" {
|
|
mountPath, ok := mounts[doc.SharedFolderID]
|
|
if !ok || mountPath == "" {
|
|
mountPath = names[doc.SharedFolderID]
|
|
if mountPath == "" {
|
|
mountPath = "Geteilter Ordner"
|
|
}
|
|
}
|
|
doc.IsSharedFolder = true
|
|
if doc.SubFolderPath != "" {
|
|
doc.FolderPath = mountPath + "/" + doc.SubFolderPath
|
|
} else {
|
|
doc.FolderPath = mountPath
|
|
}
|
|
} else {
|
|
if uf, ok := userFolders[doc.ID]; ok {
|
|
doc.FolderPath = uf
|
|
} else if !doc.IsOwner {
|
|
doc.FolderPath = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
&d.SharedFolderID, &d.SubFolderPath,
|
|
)
|
|
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.Tags = stripFolderTags(d.Tags)
|
|
return d, nil
|
|
}
|
|
|
|
func (s *Store) scanDocumentWithBlocks(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,
|
|
&d.SharedFolderID, &d.SubFolderPath,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.IsPinned = isPinned != 0
|
|
d.IsArchived = isArchived != 0
|
|
d.Tags = []string{}
|
|
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
|
d.Tags = stripFolderTags(d.Tags)
|
|
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
|
|
d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks)
|
|
d.FolderID = d.FolderPath
|
|
return d, nil
|
|
}
|
|
|
|
func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
|
mounts, names, _ := s.getUserFolderMounts(userID)
|
|
|
|
userFolders := make(map[string]string)
|
|
ufRows, err := s.db.Query(`SELECT doc_id, folder_path FROM doc_user_folders WHERE user_id = ?`, userID)
|
|
if err == nil {
|
|
for ufRows.Next() {
|
|
var did, fp string
|
|
if err := ufRows.Scan(&did, &fp); err == nil {
|
|
userFolders[did] = fp
|
|
}
|
|
}
|
|
_ = ufRows.Close()
|
|
}
|
|
|
|
// 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,
|
|
d.shared_folder_id, d.sub_folder_path
|
|
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
|
|
}
|
|
|
|
type rawDocRow struct {
|
|
doc domain.Document
|
|
blocksStr string
|
|
}
|
|
var ownRaw []rawDocRow
|
|
for rows.Next() {
|
|
d := domain.Document{}
|
|
var blocksStr, tagsStr string
|
|
var isPinned, isArchived int
|
|
if 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,
|
|
&d.SharedFolderID, &d.SubFolderPath,
|
|
); err == nil {
|
|
d.IsPinned = isPinned != 0
|
|
d.IsArchived = isArchived != 0
|
|
d.Tags = []string{}
|
|
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
|
d.Tags = stripFolderTags(d.Tags)
|
|
d.Permission = "owner"
|
|
d.IsOwner = true
|
|
resolveDocumentPath(userID, &d, mounts, names, userFolders)
|
|
ownRaw = append(ownRaw, rawDocRow{doc: d, blocksStr: blocksStr})
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
docs := make([]domain.Document, 0, len(ownRaw))
|
|
for _, r := range ownRaw {
|
|
r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr)
|
|
docs = append(docs, r.doc)
|
|
}
|
|
|
|
// 2. Fetch documents shared with the user directly, via teams, or via shared folders
|
|
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,
|
|
d.shared_folder_id, d.sub_folder_path,
|
|
CASE
|
|
WHEN wsf.owner_id = ? THEN 'owner'
|
|
WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write'
|
|
ELSE 'read'
|
|
END AS permission,
|
|
u.display_name
|
|
FROM documents d
|
|
JOIN users u ON d.user_id = u.id
|
|
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
|
LEFT JOIN (
|
|
SELECT dts2.doc_id, dts2.permission
|
|
FROM doc_team_shares dts2
|
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
|
WHERE tm.user_id = ?
|
|
) dts ON d.id = dts.doc_id
|
|
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
|
LEFT JOIN (
|
|
SELECT wfs2.folder_id, wfs2.permission
|
|
FROM workspace_folder_shares wfs2
|
|
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
|
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
) wfs ON d.shared_folder_id = wfs.folder_id
|
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
|
WHERE d.is_archived = 0 AND d.user_id != ? AND (
|
|
s.user_id IS NOT NULL OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL
|
|
)
|
|
GROUP BY d.id
|
|
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
|
userID, userID, userID, userID, userID, userID, userID, userID,
|
|
)
|
|
if err == nil {
|
|
var sharedRaw []rawDocRow
|
|
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,
|
|
&d.SharedFolderID, &d.SubFolderPath,
|
|
&perm, &ownerName,
|
|
); scanErr == nil {
|
|
d.IsPinned = isPinned != 0
|
|
d.IsArchived = isArchived != 0
|
|
d.Tags = []string{}
|
|
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
|
d.Tags = stripFolderTags(d.Tags)
|
|
d.Permission = perm
|
|
d.IsOwner = (perm == "owner")
|
|
d.OwnerName = ownerName
|
|
resolveDocumentPath(userID, &d, mounts, names, userFolders)
|
|
sharedRaw = append(sharedRaw, rawDocRow{doc: d, blocksStr: blocksStr})
|
|
}
|
|
}
|
|
_ = sharedRows.Close()
|
|
|
|
for _, r := range sharedRaw {
|
|
r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr)
|
|
docs = append(docs, r.doc)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
mounts, names, _ := s.getUserFolderMounts(userID)
|
|
userFolders := make(map[string]string)
|
|
var personalFp string
|
|
if err := s.db.QueryRow(`SELECT folder_path FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID).Scan(&personalFp); err == nil {
|
|
userFolders[docID] = personalFp
|
|
}
|
|
|
|
// 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,
|
|
d.shared_folder_id, d.sub_folder_path
|
|
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 = ? OR (d.is_archived = 1 AND d.archived_by = ?))`,
|
|
userID, docID, userID, userID,
|
|
)
|
|
doc, err := s.scanDocumentWithBlocks(row)
|
|
if err == nil {
|
|
doc.Permission = "owner"
|
|
doc.IsOwner = doc.UserID == userID
|
|
if doc.IsArchived {
|
|
doc.Permission = "read" // read-only while in the trash
|
|
}
|
|
resolveDocumentPath(userID, doc, mounts, names, userFolders)
|
|
if shares, err := s.GetDocShares(doc.ID); err == nil {
|
|
doc.SharedWith = shares
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// Try shared document (direct, via team, or via shared folder)
|
|
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,
|
|
d.shared_folder_id, d.sub_folder_path,
|
|
CASE
|
|
WHEN wsf.owner_id = ? THEN 'owner'
|
|
WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write'
|
|
ELSE 'read'
|
|
END AS permission,
|
|
u.display_name
|
|
FROM documents d
|
|
JOIN users u ON d.user_id = u.id
|
|
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
|
LEFT JOIN (
|
|
SELECT dts2.doc_id, dts2.permission
|
|
FROM doc_team_shares dts2
|
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
|
WHERE tm.user_id = ?
|
|
) dts ON d.id = dts.doc_id
|
|
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
|
LEFT JOIN (
|
|
SELECT wfs2.folder_id, wfs2.permission
|
|
FROM workspace_folder_shares wfs2
|
|
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
|
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
) wfs ON d.shared_folder_id = wfs.folder_id
|
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
|
WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)
|
|
LIMIT 1`,
|
|
userID, userID, userID, userID, userID, userID, docID, userID, 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,
|
|
&d.SharedFolderID, &d.SubFolderPath,
|
|
&perm, &ownerName,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.IsPinned = isPinned != 0
|
|
d.IsArchived = isArchived != 0
|
|
d.Tags = []string{}
|
|
_ = json.Unmarshal([]byte(tagsStr), &d.Tags)
|
|
d.Tags = stripFolderTags(d.Tags)
|
|
d.Blocks = s.loadDocBlocks(d.ID, blocksStr)
|
|
d.Permission = perm
|
|
d.IsOwner = (perm == "owner")
|
|
d.OwnerName = ownerName
|
|
resolveDocumentPath(userID, d, mounts, names, userFolders)
|
|
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, isPinned *bool) (*domain.Document, error) {
|
|
doc, err := s.GetDocumentByID(userID, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if doc.IsArchived {
|
|
return nil, ErrDocInTrash
|
|
}
|
|
|
|
// Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is
|
|
// only changed through the operation log (POST /docs/{id}/sync).
|
|
changesContent := title != nil || icon != nil
|
|
if changesContent && !CanEditDoc(s.DocRole(userID, docID)) {
|
|
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 folderPath != nil {
|
|
movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath)
|
|
if moveErr != nil {
|
|
return nil, moveErr
|
|
}
|
|
doc.FolderPath = movedDoc.FolderPath
|
|
doc.SharedFolderID = movedDoc.SharedFolderID
|
|
doc.SubFolderPath = movedDoc.SubFolderPath
|
|
doc.IsSharedFolder = movedDoc.IsSharedFolder
|
|
}
|
|
|
|
if !changesContent {
|
|
return doc, nil
|
|
}
|
|
if title != nil {
|
|
doc.Title = strings.TrimSpace(*title)
|
|
s.applyLegacyTitle(docID, userID, doc.Title)
|
|
}
|
|
if icon != nil {
|
|
doc.Icon = *icon
|
|
}
|
|
doc.UpdatedAt = domain.NowISO()
|
|
if _, err := s.db.Exec(`UPDATE documents SET icon = ?, updated_at = ? WHERE id = ?`, doc.Icon, doc.UpdatedAt, docID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetDocumentByID(userID, docID)
|
|
}
|
|
|
|
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
|
|
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
|
return s.DeleteDocShare(userID, docID, userID)
|
|
}
|
|
if doc.IsArchived {
|
|
return s.hardDeleteDocument(docID)
|
|
}
|
|
return s.trashDocument(userID, docID)
|
|
}
|
|
|
|
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 {
|
|
blocks := s.loadDocBlocks(d.id, d.blocksJSON)
|
|
if len(blocks) > 0 {
|
|
_ = s.indexDocBlockTags(d.id, d.userID, blocks)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error {
|
|
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
|
|
if len(blocks) == 0 {
|
|
return nil
|
|
}
|
|
|
|
stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
|
|
for _, b := range blocks {
|
|
if b.Content == "" {
|
|
continue
|
|
}
|
|
tags := domain.ExtractTags(b.Content)
|
|
for _, tag := range tags {
|
|
_, _ = stmt.Exec(tag, docID, b.ID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
|
|
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
|
|
if cleanTag == "" {
|
|
return "", errors.New("tag name required")
|
|
}
|
|
now := domain.NowISO()
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO tag_pages (user_id, tag, content, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(user_id, tag) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
|
|
userID, cleanTag, content, now,
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return now, nil
|
|
}
|
|
|
|
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count
|
|
FROM block_tags t
|
|
JOIN documents d ON t.document_id = d.id
|
|
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
|
LEFT JOIN (
|
|
SELECT dts2.doc_id
|
|
FROM doc_team_shares dts2
|
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
|
WHERE tm.user_id = ?
|
|
) dts ON d.id = dts.doc_id
|
|
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
|
LEFT JOIN (
|
|
SELECT wfs2.folder_id
|
|
FROM workspace_folder_shares wfs2
|
|
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
|
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
) wfs ON d.shared_folder_id = wfs.folder_id
|
|
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
|
|
AND d.is_archived = 0
|
|
GROUP BY t.tag
|
|
ORDER BY count DESC, t.tag ASC`,
|
|
userID, userID, userID, userID, userID, userID, userID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
results := make([]domain.TagSummary, 0)
|
|
seen := make(map[string]bool)
|
|
for rows.Next() {
|
|
var ts domain.TagSummary
|
|
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
|
|
results = append(results, ts)
|
|
seen[ts.Tag] = true
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
// Also include tags that have a non-empty tag_pages note even if not currently used in a block
|
|
pageRows, err := s.db.Query(
|
|
`SELECT tag FROM tag_pages WHERE user_id = ? AND TRIM(content) != '' ORDER BY tag ASC`,
|
|
userID,
|
|
)
|
|
if err == nil {
|
|
for pageRows.Next() {
|
|
var t string
|
|
if err := pageRows.Scan(&t); err == nil && t != "" && !seen[t] {
|
|
results = append(results, domain.TagSummary{Tag: t, Count: 0})
|
|
seen[t] = true
|
|
}
|
|
}
|
|
_ = pageRows.Close()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var noteContent, noteUpdatedAt string
|
|
_ = s.db.QueryRow(
|
|
`SELECT content, updated_at FROM tag_pages WHERE user_id = ? AND tag = ?`,
|
|
userID, cleanTag,
|
|
).Scan(¬eContent, ¬eUpdatedAt)
|
|
|
|
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, dts.permission, wfs.permission, '') as shared_permission
|
|
FROM block_tags t
|
|
JOIN documents d ON t.document_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 = ?
|
|
LEFT JOIN (
|
|
SELECT dts2.doc_id, dts2.permission
|
|
FROM doc_team_shares dts2
|
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
|
WHERE tm.user_id = ?
|
|
) dts ON d.id = dts.doc_id
|
|
LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id
|
|
LEFT JOIN (
|
|
SELECT wfs2.folder_id, wfs2.permission
|
|
FROM workspace_folder_shares wfs2
|
|
WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?)
|
|
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
) wfs ON d.shared_folder_id = wfs.folder_id
|
|
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)))
|
|
AND t.tag = ?
|
|
AND d.is_archived = 0
|
|
ORDER BY d.updated_at DESC`,
|
|
userID, userID, userID, userID, 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]
|
|
|
|
blocks := s.loadDocBlocks(first.docID, first.blocksJSON)
|
|
|
|
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,
|
|
NoteContent: noteContent,
|
|
NoteUpdatedAt: noteUpdatedAt,
|
|
Documents: docGroups,
|
|
TotalMatches: totalMatches,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) GetFolders(userID string) ([]string, error) {
|
|
folderSet := make(map[string]bool)
|
|
|
|
// 1. Collect from resolved documents for this user
|
|
docs, err := s.GetDocuments(userID)
|
|
if err == nil {
|
|
for _, doc := range docs {
|
|
if doc.FolderPath != "" {
|
|
addFolderSegments(doc.FolderPath, folderSet)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Add mounted shared folders (so even empty shared folders appear in the tree)
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
if err == nil {
|
|
for sfID, mPath := range mounts {
|
|
if mPath != "" {
|
|
addFolderSegments(mPath, folderSet)
|
|
|
|
// 2b. Add all subfolders created in this shared folder
|
|
sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
|
|
if err == nil {
|
|
for sRows.Next() {
|
|
var sp string
|
|
if err := sRows.Scan(&sp); err == nil && sp != "" {
|
|
fullP := mPath + "/" + sp
|
|
addFolderSegments(fullP, folderSet)
|
|
}
|
|
}
|
|
_ = sRows.Close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Add personal empty folders created by this user
|
|
pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID)
|
|
if err == nil {
|
|
for pRows.Next() {
|
|
var p string
|
|
if err := pRows.Scan(&p); err == nil && p != "" {
|
|
addFolderSegments(p, folderSet)
|
|
}
|
|
}
|
|
_ = pRows.Close()
|
|
}
|
|
|
|
result := make([]string, 0, len(folderSet))
|
|
for f := range folderSet {
|
|
result = append(result, f)
|
|
}
|
|
sort.Strings(result)
|
|
return result, nil
|
|
}
|
|
|
|
func addFolderSegments(fp string, set map[string]bool) {
|
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(fp)))
|
|
if clean == "." || clean == "" {
|
|
return
|
|
}
|
|
parts := strings.Split(clean, "/")
|
|
var cur string
|
|
for _, p := range parts {
|
|
if p != "" && p != "." {
|
|
if cur == "" {
|
|
cur = p
|
|
} else {
|
|
cur = cur + "/" + p
|
|
}
|
|
set[cur] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
var sfID, subPath string
|
|
if err == nil {
|
|
sfID, subPath, _ = matchMountPath(clean, mounts)
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
id := domain.NewID("wfld")
|
|
|
|
if sfID != "" {
|
|
var existsID string
|
|
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID)
|
|
if err == sql.ErrNoRows {
|
|
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
id, userID, sfID, clean, subPath, now)
|
|
}
|
|
} else {
|
|
var existsID string
|
|
err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID)
|
|
if err == sql.ErrNoRows {
|
|
_, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`,
|
|
id, userID, clean, now)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
var deletedDocIDs []string
|
|
seenDocID := make(map[string]bool)
|
|
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
if err == nil {
|
|
sfID, subPath, isMatch := matchMountPath(clean, mounts)
|
|
if isMatch && sfID != "" {
|
|
if subPath == "" {
|
|
// Root of a shared folder
|
|
shFolder, sfErr := s.GetSharedFolderByID(userID, sfID)
|
|
if sfErr == nil && !shFolder.IsOwner {
|
|
// Collaborator removing mount/share
|
|
_ = s.DeleteSharedFolder(userID, sfID)
|
|
return []string{}, nil
|
|
}
|
|
rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID)
|
|
if qErr == nil {
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
|
seenDocID[id] = true
|
|
deletedDocIDs = append(deletedDocIDs, id)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
}
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID)
|
|
} else {
|
|
// Subfolder inside a shared folder
|
|
rows, qErr := s.db.Query(
|
|
`SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`,
|
|
sfID, subPath, subPath+"/%",
|
|
)
|
|
if qErr == nil {
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
|
seenDocID[id] = true
|
|
deletedDocIDs = append(deletedDocIDs, id)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
}
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`,
|
|
sfID, subPath, subPath+"/%")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find all personal documents owned by this user in this folder and subfolders
|
|
rows, err := s.db.Query(
|
|
`SELECT id FROM documents
|
|
WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`,
|
|
userID, clean, clean+"/%",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil && !seenDocID[id] {
|
|
seenDocID[id] = true
|
|
deletedDocIDs = append(deletedDocIDs, id)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
// The notes of the folder go to the trash; restoring one creates its folder again
|
|
for _, id := range deletedDocIDs {
|
|
_ = s.trashDocument(userID, id)
|
|
}
|
|
|
|
// Also clear any personal folder placements for shared notes in this folder
|
|
_, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%")
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%")
|
|
|
|
return deletedDocIDs, nil
|
|
}
|
|
|
|
func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Document, error) {
|
|
doc, err := s.GetDocumentByID(userID, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if doc.IsArchived {
|
|
return nil, ErrDocInTrash
|
|
}
|
|
|
|
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath)))
|
|
if cleanTarget == "." {
|
|
cleanTarget = ""
|
|
}
|
|
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := domain.NowISO()
|
|
|
|
// Check if target folder falls within any mounted shared folder
|
|
targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts)
|
|
|
|
if isTargetShared {
|
|
// Moving into / within a shared folder
|
|
shFolder, err := s.GetSharedFolderByID(userID, targetFolderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !shFolder.IsOwner {
|
|
return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben")
|
|
}
|
|
|
|
// Update document to belong to targetSharedFolderID
|
|
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`,
|
|
targetFolderID, subPath, now, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Remove personal override if any
|
|
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
|
|
|
|
doc.SharedFolderID = targetFolderID
|
|
doc.SubFolderPath = subPath
|
|
doc.IsSharedFolder = true
|
|
doc.FolderPath = cleanTarget
|
|
return doc, nil
|
|
}
|
|
|
|
// Target is NOT inside a shared folder (personal / standalone)
|
|
if doc.SharedFolderID != "" {
|
|
// Moving OUT of a shared folder into a personal folder
|
|
if !doc.IsOwner {
|
|
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
|
|
}
|
|
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
|
|
cleanTarget, now, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc.SharedFolderID = ""
|
|
doc.SubFolderPath = ""
|
|
doc.IsSharedFolder = false
|
|
doc.FolderPath = cleanTarget
|
|
return doc, nil
|
|
}
|
|
|
|
// Standalone note
|
|
if doc.IsOwner {
|
|
// Owner moves their own document -> updates documents.folder_path
|
|
_, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc.FolderPath = cleanTarget
|
|
return doc, nil
|
|
}
|
|
|
|
// Recipient moves an individually shared note -> updates doc_user_folders ONLY!
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at
|
|
`, userID, docID, cleanTarget, now, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
doc.FolderPath = cleanTarget
|
|
return doc, nil
|
|
}
|
|
|
|
// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path.
|
|
func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) {
|
|
cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath)))
|
|
if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") {
|
|
return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad")
|
|
}
|
|
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath)))
|
|
if cleanTarget == "." {
|
|
cleanTarget = ""
|
|
}
|
|
if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) {
|
|
return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden")
|
|
}
|
|
|
|
folderName := filepath.Base(cleanSource)
|
|
var newFolderPath string
|
|
if cleanTarget == "" {
|
|
newFolderPath = folderName
|
|
} else {
|
|
newFolderPath = cleanTarget + "/" + folderName
|
|
}
|
|
|
|
if newFolderPath == cleanSource {
|
|
return cleanSource, []string{}, nil
|
|
}
|
|
|
|
// 1. Check if sourcePath is the root mount of a shared folder
|
|
mounts, _, err := s.getUserFolderMounts(userID)
|
|
if err == nil {
|
|
for sfID, mPath := range mounts {
|
|
if mPath == cleanSource {
|
|
err := s.UpdateFolderMount(userID, sfID, newFolderPath)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return newFolderPath, []string{}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Move all affected documents
|
|
docs, err := s.GetDocuments(userID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
var movedDocIDs []string
|
|
for _, doc := range docs {
|
|
if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") {
|
|
var nextDocFolder string
|
|
if doc.FolderPath == cleanSource {
|
|
nextDocFolder = newFolderPath
|
|
} else {
|
|
suffix := strings.TrimPrefix(doc.FolderPath, cleanSource)
|
|
nextDocFolder = newFolderPath + suffix
|
|
}
|
|
_, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder)
|
|
if moveErr == nil {
|
|
movedDocIDs = append(movedDocIDs, doc.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Re-map workspace_folders entries
|
|
targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts)
|
|
sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts)
|
|
|
|
wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`)
|
|
if qErr == nil {
|
|
type wfRecord struct {
|
|
id, uid, sfID, path, subPath string
|
|
}
|
|
var records []wfRecord
|
|
for wfRows.Next() {
|
|
var r wfRecord
|
|
if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil {
|
|
records = append(records, r)
|
|
}
|
|
}
|
|
_ = wfRows.Close()
|
|
|
|
for _, r := range records {
|
|
if isSourceSF && r.sfID == sourceSFID {
|
|
matches := false
|
|
var suffix string
|
|
if sourceSub == "" {
|
|
matches = true
|
|
suffix = "/" + r.subPath
|
|
} else if r.subPath == sourceSub {
|
|
matches = true
|
|
suffix = ""
|
|
} else if strings.HasPrefix(r.subPath, sourceSub+"/") {
|
|
matches = true
|
|
suffix = strings.TrimPrefix(r.subPath, sourceSub)
|
|
}
|
|
if matches {
|
|
var newSub string
|
|
if targetSub == "" {
|
|
newSub = strings.TrimPrefix(folderName+suffix, "/")
|
|
} else {
|
|
newSub = targetSub + "/" + folderName + suffix
|
|
}
|
|
if isTargetSF {
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id)
|
|
} else {
|
|
newP := newFolderPath + suffix
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id)
|
|
}
|
|
}
|
|
} else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") {
|
|
suffix := strings.TrimPrefix(r.path, cleanSource)
|
|
newP := newFolderPath + suffix
|
|
if isTargetSF {
|
|
var newSub string
|
|
if targetSub == "" {
|
|
newSub = strings.TrimPrefix(folderName+suffix, "/")
|
|
} else {
|
|
newSub = targetSub + "/" + folderName + suffix
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id)
|
|
} else {
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_, _ = s.CreateFolder(userID, newFolderPath)
|
|
|
|
return newFolderPath, movedDocIDs, nil
|
|
}
|
|
|
|
// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID.
|
|
func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) {
|
|
if ownerID == "" || folderID == "" || cleanMount == "" {
|
|
return
|
|
}
|
|
|
|
// 1. Link all documents owned by ownerID in cleanMount to this shared folder
|
|
rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`,
|
|
ownerID, cleanMount, cleanMount+"/%")
|
|
if err == nil {
|
|
type docEntry struct{ id, fp string }
|
|
var entries []docEntry
|
|
for rows.Next() {
|
|
var de docEntry
|
|
if err := rows.Scan(&de.id, &de.fp); err == nil {
|
|
entries = append(entries, de)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
for _, de := range entries {
|
|
var subPath string
|
|
if de.fp == cleanMount {
|
|
subPath = ""
|
|
} else if strings.HasPrefix(de.fp, cleanMount+"/") {
|
|
subPath = strings.TrimPrefix(de.fp, cleanMount+"/")
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`,
|
|
folderID, subPath, de.id)
|
|
}
|
|
}
|
|
|
|
// 2. Link any workspace_folders created by ownerID in cleanMount
|
|
fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`,
|
|
ownerID, cleanMount, cleanMount+"/%")
|
|
if err == nil {
|
|
type wfEntry struct{ id, path string }
|
|
var wfEntries []wfEntry
|
|
for fRows.Next() {
|
|
var wfe wfEntry
|
|
if err := fRows.Scan(&wfe.id, &wfe.path); err == nil {
|
|
wfEntries = append(wfEntries, wfe)
|
|
}
|
|
}
|
|
_ = fRows.Close()
|
|
|
|
for _, wfe := range wfEntries {
|
|
subP := ""
|
|
if strings.HasPrefix(wfe.path, cleanMount+"/") {
|
|
subP = strings.TrimPrefix(wfe.path, cleanMount+"/")
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`,
|
|
folderID, subP, wfe.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) {
|
|
cleanName := strings.TrimSpace(folderName)
|
|
if cleanName == "" {
|
|
return nil, fmt.Errorf("ordnername darf nicht leer sein")
|
|
}
|
|
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath)))
|
|
if cleanMount == "." || cleanMount == "" {
|
|
cleanMount = cleanName
|
|
}
|
|
|
|
// Check if already shared by this user at this mount path
|
|
var existingID string
|
|
err := s.db.QueryRow(`
|
|
SELECT f.id FROM workspace_shared_folders f
|
|
JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
|
WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?)
|
|
`, userID, userID, cleanMount, cleanName).Scan(&existingID)
|
|
if err == nil && existingID != "" {
|
|
s.syncSharedFolderContents(userID, existingID, cleanMount)
|
|
return s.GetSharedFolderByID(userID, existingID)
|
|
}
|
|
|
|
now := domain.NowISO()
|
|
folderID := domain.NewID("fld")
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, folderID, userID, cleanName, now, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, userID, folderID, cleanMount, now, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.syncSharedFolderContents(userID, folderID, cleanMount)
|
|
|
|
var ownerName string
|
|
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName)
|
|
|
|
return &domain.SharedFolder{
|
|
ID: folderID,
|
|
OwnerID: userID,
|
|
OwnerName: ownerName,
|
|
Name: cleanName,
|
|
MountPath: cleanMount,
|
|
IsOwner: true,
|
|
Permission: "owner",
|
|
Shares: []domain.FolderShare{},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) GetSharedFolders(userID string) ([]domain.SharedFolder, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT f.id, f.owner_id, u.display_name, f.name,
|
|
COALESCE(m.mount_path, f.name) as mount_path,
|
|
CASE
|
|
WHEN f.owner_id = ? THEN 1
|
|
ELSE 0
|
|
END as is_owner,
|
|
CASE
|
|
WHEN f.owner_id = ? THEN 'owner'
|
|
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
|
|
ELSE 'read'
|
|
END as permission,
|
|
f.created_at, f.updated_at
|
|
FROM workspace_shared_folders f
|
|
JOIN users u ON f.owner_id = u.id
|
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
|
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
|
(s.target_type = 'user' AND s.target_id = ?) OR
|
|
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)
|
|
WHERE f.owner_id = ? OR s.id IS NOT NULL
|
|
GROUP BY f.id
|
|
ORDER BY f.created_at ASC
|
|
`, userID, userID, userID, userID, userID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
folders := make([]domain.SharedFolder, 0)
|
|
for rows.Next() {
|
|
var f domain.SharedFolder
|
|
var isOwner int
|
|
if err := rows.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err == nil {
|
|
f.IsOwner = isOwner != 0
|
|
if f.IsOwner {
|
|
if shares, err := s.GetFolderShares(f.ID); err == nil {
|
|
f.Shares = shares
|
|
}
|
|
}
|
|
folders = append(folders, f)
|
|
}
|
|
}
|
|
return folders, nil
|
|
}
|
|
|
|
func (s *Store) GetSharedFolderByID(userID, folderID string) (*domain.SharedFolder, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT f.id, f.owner_id, u.display_name, f.name,
|
|
COALESCE(m.mount_path, f.name) as mount_path,
|
|
CASE
|
|
WHEN f.owner_id = ? THEN 1
|
|
ELSE 0
|
|
END as is_owner,
|
|
CASE
|
|
WHEN f.owner_id = ? THEN 'owner'
|
|
WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write'
|
|
ELSE 'read'
|
|
END as permission,
|
|
f.created_at, f.updated_at
|
|
FROM workspace_shared_folders f
|
|
JOIN users u ON f.owner_id = u.id
|
|
LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ?
|
|
LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND (
|
|
(s.target_type = 'user' AND s.target_id = ?) OR
|
|
(s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)
|
|
WHERE f.id = ? AND (f.owner_id = ? OR s.id IS NOT NULL)
|
|
GROUP BY f.id
|
|
`, userID, userID, userID, userID, userID, folderID, userID)
|
|
|
|
var f domain.SharedFolder
|
|
var isOwner int
|
|
if err := row.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("geteilter Ordner nicht gefunden")
|
|
}
|
|
f.IsOwner = isOwner != 0
|
|
if f.IsOwner {
|
|
if shares, err := s.GetFolderShares(f.ID); err == nil {
|
|
f.Shares = shares
|
|
}
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
func (s *Store) UpdateFolderMount(userID, folderID, mountPath string) error {
|
|
cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mountPath)))
|
|
if cleanMount == "." || cleanMount == "" {
|
|
return fmt.Errorf("ungültiger Mount-Pfad")
|
|
}
|
|
now := domain.NowISO()
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, folder_id) DO UPDATE SET mount_path = excluded.mount_path, updated_at = excluded.updated_at
|
|
`, userID, folderID, cleanMount, now, now)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) GetFolderShares(folderID string) ([]domain.FolderShare, error) {
|
|
shares := make([]domain.FolderShare, 0)
|
|
rows, err := s.db.Query(`
|
|
SELECT s.id, s.folder_id, s.target_type, s.target_id,
|
|
CASE
|
|
WHEN s.target_type = 'user' THEN u.display_name
|
|
WHEN s.target_type = 'team' THEN t.name
|
|
ELSE ''
|
|
END as target_name,
|
|
COALESCE(u.email, '') as email,
|
|
COALESCE(u.avatar_url, '') as avatar_url,
|
|
s.permission, s.created_by, s.created_at
|
|
FROM workspace_folder_shares s
|
|
LEFT JOIN users u ON s.target_type = 'user' AND s.target_id = u.id
|
|
LEFT JOIN teams t ON s.target_type = 'team' AND s.target_id = t.id
|
|
WHERE s.folder_id = ?
|
|
ORDER BY s.created_at ASC
|
|
`, folderID)
|
|
if err != nil {
|
|
return shares, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var sh domain.FolderShare
|
|
if err := rows.Scan(&sh.ID, &sh.FolderID, &sh.TargetType, &sh.TargetID, &sh.TargetName, &sh.Email, &sh.AvatarURL, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
|
shares = append(shares, sh)
|
|
}
|
|
}
|
|
return shares, nil
|
|
}
|
|
|
|
func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIdentifier, permission string) (*domain.FolderShare, error) {
|
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !f.IsOwner {
|
|
return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten")
|
|
}
|
|
|
|
cleanType := strings.TrimSpace(targetType)
|
|
cleanTarget := strings.TrimSpace(targetIdentifier)
|
|
if cleanType == "" {
|
|
if strings.HasPrefix(cleanTarget, "team_") {
|
|
cleanType = "team"
|
|
} else {
|
|
cleanType = "user"
|
|
}
|
|
}
|
|
|
|
permission = normalizeSharePermission(permission)
|
|
|
|
var targetID, targetName, email, avatarURL string
|
|
if cleanType == "team" {
|
|
err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName)
|
|
if err != nil {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
} else {
|
|
var u *domain.User
|
|
if strings.HasPrefix(cleanTarget, "usr_") {
|
|
u, _ = s.GetUserByID(cleanTarget)
|
|
}
|
|
if u == nil {
|
|
u, _ = s.GetUserByLogin(cleanTarget)
|
|
}
|
|
if u == nil {
|
|
u, err = s.GetUserByID(cleanTarget)
|
|
}
|
|
if err != nil || u == nil {
|
|
if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil {
|
|
cleanType = "team"
|
|
} else {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
} else {
|
|
if u.ID == f.OwnerID {
|
|
return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer")
|
|
}
|
|
targetID = u.ID
|
|
targetName = u.DisplayName
|
|
email = u.Email
|
|
avatarURL = u.AvatarURL
|
|
}
|
|
}
|
|
|
|
if !s.CanShareWith(currentUserID, cleanType, targetID) {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
|
|
now := domain.NowISO()
|
|
shareID := domain.NewID("fsh")
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission
|
|
`, shareID, folderID, cleanType, targetID, permission, currentUserID, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ownerMount string
|
|
_ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount)
|
|
if ownerMount == "" {
|
|
ownerMount = f.Name
|
|
}
|
|
s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount)
|
|
|
|
if cleanType == "user" {
|
|
_, _ = s.db.Exec(`
|
|
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, targetID, folderID, f.Name, now, now)
|
|
} else if cleanType == "team" {
|
|
mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID)
|
|
if err == nil {
|
|
var memberIDs []string
|
|
for mRows.Next() {
|
|
var uid string
|
|
if err := mRows.Scan(&uid); err == nil && uid != "" {
|
|
memberIDs = append(memberIDs, uid)
|
|
}
|
|
}
|
|
_ = mRows.Close()
|
|
for _, uid := range memberIDs {
|
|
_, _ = s.db.Exec(`
|
|
INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, uid, folderID, f.Name, now, now)
|
|
}
|
|
}
|
|
}
|
|
|
|
return &domain.FolderShare{
|
|
ID: shareID,
|
|
FolderID: folderID,
|
|
TargetType: cleanType,
|
|
TargetID: targetID,
|
|
TargetName: targetName,
|
|
Email: email,
|
|
AvatarURL: avatarURL,
|
|
Permission: permission,
|
|
CreatedBy: currentUserID,
|
|
CreatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error {
|
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cleanID := strings.TrimSpace(shareID)
|
|
|
|
// Only the owner manages shares; everyone else can only leave the folder
|
|
if !f.IsOwner {
|
|
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`,
|
|
folderID, currentUserID, cleanID, cleanID)
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID)
|
|
return err
|
|
}
|
|
|
|
var targetType, targetID string
|
|
_ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`,
|
|
folderID, cleanID, cleanID).Scan(&targetType, &targetID)
|
|
|
|
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if targetType == "user" && targetID != "" {
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error {
|
|
f, err := s.GetSharedFolderByID(currentUserID, folderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !f.IsOwner {
|
|
return s.DeleteFolderShare(currentUserID, folderID, currentUserID)
|
|
}
|
|
|
|
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID)
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID)
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID)
|
|
_, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID)
|
|
_, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)
|
|
return err
|
|
}
|
|
|
|
// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder.
|
|
func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) {
|
|
rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var docIDs []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err == nil && id != "" {
|
|
docIDs = append(docIDs, id)
|
|
}
|
|
}
|
|
return docIDs, nil
|
|
}
|
|
|
|
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
|
shares := make([]domain.DocShare, 0)
|
|
|
|
// 1. Direct user shares
|
|
uRows, 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 {
|
|
for uRows.Next() {
|
|
var sh domain.DocShare
|
|
if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
|
sh.TargetType = "user"
|
|
sh.TargetID = sh.UserID
|
|
sh.TargetName = sh.DisplayName
|
|
shares = append(shares, sh)
|
|
}
|
|
}
|
|
_ = uRows.Close()
|
|
}
|
|
|
|
// 2. Team shares
|
|
tRows, err := s.db.Query(
|
|
`SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at
|
|
FROM doc_team_shares s
|
|
JOIN teams t ON s.team_id = t.id
|
|
WHERE s.doc_id = ?
|
|
ORDER BY s.created_at ASC`,
|
|
docID,
|
|
)
|
|
if err == nil {
|
|
for tRows.Next() {
|
|
var sh domain.DocShare
|
|
var teamID, teamName string
|
|
if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
|
sh.TargetType = "team"
|
|
sh.TargetID = teamID
|
|
sh.TargetName = teamName
|
|
sh.DisplayName = teamName
|
|
shares = append(shares, sh)
|
|
}
|
|
}
|
|
_ = tRows.Close()
|
|
}
|
|
|
|
return shares, nil
|
|
}
|
|
|
|
func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) {
|
|
doc, err := s.GetDocumentByID(currentUserID, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !doc.IsOwner {
|
|
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
|
}
|
|
|
|
cleanTeamID := strings.TrimSpace(teamID)
|
|
var teamName string
|
|
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
|
|
if err != nil || !s.CanShareWith(currentUserID, "team", cleanTeamID) {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
|
|
permission = normalizeSharePermission(permission)
|
|
|
|
now := domain.NowISO()
|
|
shareID := domain.NewID("share")
|
|
_, err = s.db.Exec(
|
|
`INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`,
|
|
shareID, docID, cleanTeamID, permission, now, currentUserID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &domain.DocShare{
|
|
ID: shareID,
|
|
DocID: docID,
|
|
TargetType: "team",
|
|
TargetID: cleanTeamID,
|
|
TargetName: teamName,
|
|
DisplayName: teamName,
|
|
Permission: permission,
|
|
CreatedBy: currentUserID,
|
|
CreatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
// normalizeSharePermission maps share roles from the API to the stored values: "write" (editor) or "read" (viewer).
|
|
func normalizeSharePermission(p string) string {
|
|
switch strings.ToLower(strings.TrimSpace(p)) {
|
|
case "write", "editor":
|
|
return "write"
|
|
}
|
|
return "read"
|
|
}
|
|
|
|
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.IsArchived {
|
|
return nil, ErrDocInTrash
|
|
}
|
|
if !doc.IsOwner {
|
|
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
|
}
|
|
|
|
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
|
|
|
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
|
if strings.HasPrefix(cleanTarget, "team_") {
|
|
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
|
}
|
|
|
|
permission = normalizeSharePermission(permission)
|
|
|
|
// Resolve target user by ID, username, or Email
|
|
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 {
|
|
// Check if it's a team ID that doesn't start with team_
|
|
var tName string
|
|
if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil {
|
|
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
|
}
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
|
|
if targetUser.ID == doc.UserID {
|
|
return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer")
|
|
}
|
|
if !s.CanShareWith(currentUserID, "user", targetUser.ID) {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
|
|
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,
|
|
TargetType: "user",
|
|
TargetID: targetUser.ID,
|
|
TargetName: targetUser.DisplayName,
|
|
UserID: targetUser.ID,
|
|
UserEmail: targetUser.Email,
|
|
DisplayName: targetUser.DisplayName,
|
|
Permission: permission,
|
|
CreatedBy: currentUserID,
|
|
CreatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
|
|
doc, err := s.GetDocumentByID(currentUserID, docID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Only the owner manages shares; everyone else can only remove their own
|
|
if !doc.IsOwner && currentUserID != targetID {
|
|
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
|
|
}
|
|
|
|
cleanTarget := strings.TrimSpace(targetID)
|
|
// Try deleting team share first
|
|
res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget)
|
|
if err == nil {
|
|
if rows, _ := res.RowsAffected(); rows > 0 {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Fallback to user share
|
|
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget)
|
|
return err
|
|
}
|
|
|
|
// GetCollaborators returns the active people the user can share with (everyone visible except the user).
|
|
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
|
|
visible, err := s.GetDiscoverableUsers(currentUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
users := make([]domain.User, 0, len(visible))
|
|
for _, u := range visible {
|
|
if u.ID != currentUserID && u.IsActive {
|
|
users = append(users, u)
|
|
}
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
// --- STANDALONE REALTIME LISTS & SHARING ---
|
|
|
|
func (s *Store) CreateList(userID, 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"),
|
|
UserID: userID,
|
|
IsOwner: true,
|
|
Role: "editor",
|
|
Title: strings.TrimSpace(title),
|
|
Icon: icon,
|
|
ListType: listType,
|
|
Items: []domain.ListItem{},
|
|
Shares: []domain.ListShare{},
|
|
UpdatedAt: now,
|
|
CreatedAt: now,
|
|
}
|
|
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName)
|
|
return lst, nil
|
|
}
|
|
|
|
func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, 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")
|
|
}
|
|
if role != "viewer" && role != "editor" {
|
|
role = "editor"
|
|
}
|
|
|
|
if targetType == "user" {
|
|
var targetUser *domain.User
|
|
if strings.HasPrefix(targetID, "usr_") {
|
|
targetUser, _ = s.GetUserByID(targetID)
|
|
}
|
|
if targetUser == nil {
|
|
targetUser, _ = s.GetUserByLogin(targetID)
|
|
}
|
|
if targetUser == nil {
|
|
targetUser, _ = s.GetUserByEmail(targetID)
|
|
}
|
|
if targetUser == nil {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
targetID = targetUser.ID
|
|
}
|
|
|
|
// Only the owner shares
|
|
var ownerID string
|
|
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
|
if err != nil {
|
|
return nil, errors.New("liste nicht gefunden")
|
|
}
|
|
if ownerID != currentUserID {
|
|
return nil, errors.New("nur der Eigentümer kann die Liste freigeben")
|
|
}
|
|
if !s.CanShareWith(currentUserID, targetType, targetID) {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
|
|
now := domain.NowISO()
|
|
share := &domain.ListShare{
|
|
ID: domain.NewID("lsh"),
|
|
ListID: listID,
|
|
TargetType: targetType,
|
|
TargetID: targetID,
|
|
Role: role,
|
|
CreatedBy: currentUserID,
|
|
CreatedAt: now,
|
|
}
|
|
|
|
_, err = s.db.Exec(
|
|
`INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`,
|
|
share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`,
|
|
listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
|
|
|
|
if targetType == "team" {
|
|
_ = 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) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error {
|
|
var ownerID string
|
|
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
|
if err != nil {
|
|
return errors.New("liste nicht gefunden")
|
|
}
|
|
if ownerID != currentUserID {
|
|
return errors.New("nur der Eigentümer kann Freigaben entfernen")
|
|
}
|
|
|
|
cleanTarget := strings.TrimSpace(shareIDOrTargetID)
|
|
_, err = s.db.Exec(
|
|
`DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`,
|
|
listID, cleanTarget, cleanTarget,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT id, list_id, target_type, target_id, role, created_by, created_at
|
|
FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`,
|
|
listID,
|
|
)
|
|
if err != nil {
|
|
return []domain.ListShare{}, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
shares := make([]domain.ListShare, 0)
|
|
for rows.Next() {
|
|
var sh domain.ListShare
|
|
if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
|
if sh.TargetType == "team" {
|
|
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName)
|
|
} else {
|
|
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email)
|
|
}
|
|
shares = append(shares, sh)
|
|
}
|
|
}
|
|
return shares, nil
|
|
}
|
|
|
|
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
|
u.display_name,
|
|
CASE
|
|
WHEN l.user_id = ? THEN 'owner'
|
|
WHEN ls.role IS NOT NULL THEN ls.role
|
|
ELSE 'editor'
|
|
END as effective_role
|
|
FROM lists l
|
|
LEFT JOIN users u ON u.id = l.user_id
|
|
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
|
(ls.target_type = 'user' AND ls.target_id = ?)
|
|
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)
|
|
WHERE l.user_id = ?
|
|
OR ls.id IS NOT NULL
|
|
ORDER BY l.updated_at DESC`,
|
|
userID, userID, userID, userID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lists := make([]domain.SharedList, 0)
|
|
for rows.Next() {
|
|
var l domain.SharedList
|
|
var ownerName sql.NullString
|
|
var effectiveRole string
|
|
if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil {
|
|
l.OwnerName = ownerName.String
|
|
l.IsOwner = (l.UserID == userID)
|
|
l.Role = effectiveRole
|
|
lists = append(lists, l)
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
|
|
for i := range lists {
|
|
lists[i].Items, _ = s.GetListItems(lists[i].ID)
|
|
lists[i].Shares, _ = s.GetListShares(lists[i].ID)
|
|
lists[i].Members = s.getListMembers(lists[i].ID, lists[i].UserID)
|
|
}
|
|
return lists, nil
|
|
}
|
|
|
|
func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) {
|
|
var l domain.SharedList
|
|
var ownerName sql.NullString
|
|
var effectiveRole string
|
|
|
|
var query string
|
|
var args []interface{}
|
|
if userID == "" {
|
|
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
|
u.display_name, 'owner' as effective_role
|
|
FROM lists l
|
|
LEFT JOIN users u ON u.id = l.user_id
|
|
WHERE l.id = ?`
|
|
args = []interface{}{listID}
|
|
} else {
|
|
query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at,
|
|
u.display_name,
|
|
CASE
|
|
WHEN l.user_id = ? THEN 'owner'
|
|
WHEN ls.role IS NOT NULL THEN ls.role
|
|
ELSE 'editor'
|
|
END as effective_role
|
|
FROM lists l
|
|
LEFT JOIN users u ON u.id = l.user_id
|
|
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
|
(ls.target_type = 'user' AND ls.target_id = ?)
|
|
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)
|
|
WHERE l.id = ? AND (
|
|
l.user_id = ?
|
|
OR ls.id IS NOT NULL
|
|
)`
|
|
args = []interface{}{userID, userID, userID, listID, userID}
|
|
}
|
|
|
|
err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
l.OwnerName = ownerName.String
|
|
l.IsOwner = (userID == "" || l.UserID == userID)
|
|
l.Role = effectiveRole
|
|
l.Items, _ = s.GetListItems(l.ID)
|
|
l.Shares, _ = s.GetListShares(l.ID)
|
|
l.Members = s.getListMembers(l.ID, l.UserID)
|
|
return &l, nil
|
|
}
|
|
|
|
// getListMembers returns the owner and everyone a list is shared with (directly or via a team).
|
|
func (s *Store) getListMembers(listID, ownerID string) []domain.TaskListMember {
|
|
members := make([]domain.TaskListMember, 0)
|
|
rows, err := s.db.Query(
|
|
`SELECT id, display_name, email, avatar_url FROM users WHERE id IN (
|
|
SELECT user_id FROM lists WHERE id = ?
|
|
UNION SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'user'
|
|
UNION SELECT user_id FROM team_members WHERE team_id IN (SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'team')
|
|
) ORDER BY display_name`,
|
|
listID, listID, listID,
|
|
)
|
|
if err != nil {
|
|
return members
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var m domain.TaskListMember
|
|
if rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL) == nil {
|
|
m.Role = "member"
|
|
if m.UserID == ownerID {
|
|
m.Role = "owner"
|
|
}
|
|
members = append(members, m)
|
|
}
|
|
}
|
|
return members
|
|
}
|
|
|
|
func (s *Store) DeleteList(userID, listID string) error {
|
|
var ownerID string
|
|
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
|
|
if err != nil {
|
|
return errors.New("liste nicht gefunden")
|
|
}
|
|
if ownerID != userID {
|
|
return errors.New("nur der Eigentümer kann die Liste löschen")
|
|
}
|
|
_, 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 li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
|
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
|
|
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
|
WHERE li.list_id = ?
|
|
ORDER BY li.is_completed ASC, li.sort_order ASC, li.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.CreatedBy, &it.CreatedByName, &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, createdBy string) (*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,
|
|
CreatedBy: createdBy,
|
|
CreatedAt: now,
|
|
}
|
|
if createdBy != "" {
|
|
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName)
|
|
}
|
|
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by)
|
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
|
|
it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy,
|
|
)
|
|
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) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) {
|
|
now := domain.NowISO()
|
|
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
|
|
|
|
if content != nil {
|
|
it.Content = *content
|
|
}
|
|
if category != nil {
|
|
it.Category = *category
|
|
}
|
|
if quantity != nil {
|
|
it.Quantity = *quantity
|
|
}
|
|
|
|
_, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
|
return &it, nil
|
|
}
|
|
|
|
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
|
|
// Linking changes the block (its tick follows the task), so edit rights are needed
|
|
if !CanEditDoc(s.DocRole(userID, docID)) {
|
|
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
|
}
|
|
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.ConvertedType = "document_block"
|
|
t.ConvertedRefID = docID + ":" + blockID
|
|
t.DocID = docID
|
|
t.BlockID = blockID
|
|
t.IsCompleted = checked
|
|
t.Status = domain.StatusKept
|
|
doneInt := 0
|
|
if checked {
|
|
doneInt = 1
|
|
t.KanbanStatus = "done"
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
|
|
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
|
|
|
|
s.SetBlockChecked(userID, docID, blockID, checked)
|
|
return t, nil
|
|
}
|
|
|
|
// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened).
|
|
// Returns the note ID if the block changed, so the caller can notify its viewers.
|
|
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string {
|
|
t, err := s.GetThoughtByID(userID, taskID)
|
|
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
|
|
return ""
|
|
}
|
|
docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":")
|
|
if s.SetBlockChecked(userID, docID, blockID, isCompleted) {
|
|
return docID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's
|
|
// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change.
|
|
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) {
|
|
if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil {
|
|
return
|
|
}
|
|
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID)
|
|
}
|
|
|
|
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
|
|
if taskID == "" {
|
|
return
|
|
}
|
|
doneInt := 0
|
|
kanban := "todo"
|
|
if checked {
|
|
doneInt = 1
|
|
kanban = "done"
|
|
}
|
|
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END
|
|
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt)
|
|
if checked && res != nil {
|
|
if n, _ := res.RowsAffected(); n > 0 {
|
|
s.spawnNextOccurrence(taskID)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for _, it := range items {
|
|
if it.Category != nil && it.Content != nil {
|
|
_, err = tx.Exec(
|
|
`UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`,
|
|
it.SortOrder, *it.Category, *it.Content, it.ID, listID,
|
|
)
|
|
} else if it.Category != nil {
|
|
_, err = tx.Exec(
|
|
`UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`,
|
|
it.SortOrder, *it.Category, it.ID, listID,
|
|
)
|
|
} else if it.Content != nil {
|
|
_, err = tx.Exec(
|
|
`UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`,
|
|
it.SortOrder, *it.Content, it.ID, listID,
|
|
)
|
|
} else {
|
|
_, err = tx.Exec(
|
|
`UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`,
|
|
it.SortOrder, it.ID, listID,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
|
return tx.Commit()
|
|
}
|
|
|
|
// --- 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)
|
|
// Same tasks as GetTasks without a list ("Alle Aufgaben"): own ones plus those in shared task lists
|
|
taskScope := `(user_id = ? OR task_list_id IN (` + s.accessibleTaskListIDsQuery() + `)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`
|
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 0`, userID, userID, userID, userID).Scan(&st.TasksOpenCount)
|
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 1`, userID, userID, userID, 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(DISTINCT l.id)
|
|
FROM lists l
|
|
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
|
(ls.target_type = 'user' AND ls.target_id = ?)
|
|
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)
|
|
WHERE l.user_id = ? OR ls.id IS NOT NULL`,
|
|
userID, userID, 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) {
|
|
res, err := s.SearchGlobal(userID, query)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return res.Thoughts, res.Documents, 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)
|
|
lists, _ := s.GetAllListsForUser(userID)
|
|
taskLists, _ := s.GetTaskLists(userID)
|
|
|
|
return &domain.FullExport{
|
|
ExportedAt: domain.NowISO(),
|
|
Version: "1.0.0",
|
|
User: *user,
|
|
Thoughts: thoughts,
|
|
Documents: docs,
|
|
Lists: lists,
|
|
TaskLists: taskLists,
|
|
}, nil
|
|
}
|
|
|
|
// --- TASK LISTS ---
|
|
|
|
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string, columns ...[]domain.KanbanColumn) (*domain.TaskList, error) {
|
|
if strings.TrimSpace(title) == "" {
|
|
title = "Meine Aufgaben"
|
|
}
|
|
if icon == "" {
|
|
icon = "📋"
|
|
}
|
|
if color == "" {
|
|
color = "#2563eb"
|
|
}
|
|
cols := []domain.KanbanColumn{
|
|
{ID: "todo", Name: "Zu erledigen"},
|
|
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
|
|
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
|
|
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
|
|
}
|
|
if len(columns) > 0 && len(columns[0]) > 0 {
|
|
cols = columns[0]
|
|
}
|
|
colsJSON, _ := json.Marshal(cols)
|
|
now := domain.NowISO()
|
|
tl := &domain.TaskList{
|
|
ID: domain.NewID("tkl"),
|
|
UserID: userID,
|
|
SpaceID: spaceID,
|
|
Title: strings.TrimSpace(title),
|
|
Icon: icon,
|
|
Color: color,
|
|
Columns: cols,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
IsOwner: true,
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO task_lists (id, user_id, space_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if teamID != "" && s.CanShareWith(userID, "team", teamID) {
|
|
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
|
|
}
|
|
return s.GetTaskListByID(userID, tl.ID)
|
|
}
|
|
|
|
// ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks.
|
|
var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)")
|
|
|
|
// ErrAssigneeNoAccess is returned when a task should be assigned to someone who cannot see its list.
|
|
var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff auf die Liste zugewiesen werden")
|
|
|
|
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
|
|
func (s *Store) GetTaskListPermission(userID, listID string) string {
|
|
var ownerID string
|
|
if err := s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&ownerID); err != nil {
|
|
return ""
|
|
}
|
|
if ownerID == userID {
|
|
return "owner"
|
|
}
|
|
rows, err := s.db.Query(
|
|
`SELECT permission FROM task_list_shares
|
|
WHERE task_list_id = ? AND (
|
|
(target_type = 'user' AND target_id = ?)
|
|
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
|
)`,
|
|
listID, userID, userID,
|
|
)
|
|
perm := ""
|
|
if err == nil {
|
|
for rows.Next() {
|
|
var p string
|
|
if rows.Scan(&p) == nil {
|
|
if p == "viewer" {
|
|
if perm == "" {
|
|
perm = "viewer"
|
|
}
|
|
} else {
|
|
perm = "editor"
|
|
}
|
|
}
|
|
}
|
|
_ = rows.Close()
|
|
}
|
|
return perm
|
|
}
|
|
|
|
func (s *Store) CanEditTaskList(userID, listID string) bool {
|
|
p := s.GetTaskListPermission(userID, listID)
|
|
return p == "owner" || p == "editor"
|
|
}
|
|
|
|
// ensureTaskListWritable returns ErrTaskListReadOnly if the list is set and the user may not modify it.
|
|
func (s *Store) ensureTaskListWritable(userID, listID string) error {
|
|
if listID == "" || s.CanEditTaskList(userID, listID) {
|
|
return nil
|
|
}
|
|
return ErrTaskListReadOnly
|
|
}
|
|
|
|
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, 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")
|
|
}
|
|
if permission != "viewer" {
|
|
permission = "editor"
|
|
}
|
|
if !s.CanShareWith(createdBy, targetType, targetID) {
|
|
return nil, ErrTargetNotFound
|
|
}
|
|
now := domain.NowISO()
|
|
share := &domain.TaskListShare{
|
|
ID: domain.NewID("tls"),
|
|
TaskListID: taskListID,
|
|
TargetType: targetType,
|
|
TargetID: targetID,
|
|
Permission: permission,
|
|
CreatedAt: now,
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, permission, created_at, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at, permission = excluded.permission`,
|
|
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.Permission, share.CreatedAt, createdBy,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`,
|
|
taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
|
|
|
|
if targetType == "team" {
|
|
_ = 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 {
|
|
_, err := s.db.Exec(
|
|
`DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`,
|
|
taskListID, shareIDOrTargetID, shareIDOrTargetID,
|
|
)
|
|
s.PruneTaskAssignees()
|
|
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, permission, 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.Permission, &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, avatar_url FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email, &shares[i].AvatarURL)
|
|
}
|
|
}
|
|
return shares, nil
|
|
}
|
|
|
|
// queryUserIDs runs a query returning a single user_id column and collects the distinct IDs.
|
|
func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return []string{}
|
|
}
|
|
defer rows.Close()
|
|
ids := make([]string, 0)
|
|
seen := make(map[string]bool)
|
|
for rows.Next() {
|
|
var id string
|
|
if rows.Scan(&id) == nil && id != "" && !seen[id] {
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// taskListAccessSQL selects every user ID with access to a task list: owner, direct shares and members of
|
|
// shared groups. %[1]s is the list ID expression ("?" or a correlated column).
|
|
const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s
|
|
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user'
|
|
UNION SELECT tm.user_id FROM team_members tm
|
|
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')`
|
|
|
|
func (s *Store) getTaskAssignees(taskID string) []string {
|
|
ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID)
|
|
return ids
|
|
}
|
|
|
|
// attachTaskAssignees fills Assignees for a batch of tasks with a single query.
|
|
func (s *Store) attachTaskAssignees(tasks []domain.Thought) {
|
|
if len(tasks) == 0 {
|
|
return
|
|
}
|
|
idx := make(map[string]int, len(tasks))
|
|
placeholders := make([]string, 0, len(tasks))
|
|
args := make([]interface{}, 0, len(tasks))
|
|
for i := range tasks {
|
|
idx[tasks[i].ID] = i
|
|
placeholders = append(placeholders, "?")
|
|
args = append(args, tasks[i].ID)
|
|
}
|
|
rows, err := s.db.Query(
|
|
`SELECT task_id, user_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY created_at ASC`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var taskID, uid string
|
|
if rows.Scan(&taskID, &uid) == nil {
|
|
if i, ok := idx[taskID]; ok {
|
|
tasks[i].Assignees = append(tasks[i].Assignees, uid)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetTaskAssignees replaces the assignees of a task. Only editors of the task's list may assign, and only
|
|
// people who can see the list are accepted.
|
|
func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]string, error) {
|
|
thought, err := s.GetThoughtByID(userID, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if thought.TaskListID == "" {
|
|
if len(assignees) > 0 {
|
|
return nil, ErrAssigneeNoAccess
|
|
}
|
|
return []string{}, nil
|
|
}
|
|
if !s.CanEditTaskList(userID, thought.TaskListID) {
|
|
return nil, ErrTaskListReadOnly
|
|
}
|
|
allowed := make(map[string]bool)
|
|
for _, id := range s.GetTaskListUserIDs(thought.TaskListID) {
|
|
allowed[id] = true
|
|
}
|
|
clean := make([]string, 0, len(assignees))
|
|
seen := make(map[string]bool)
|
|
for _, id := range assignees {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || seen[id] {
|
|
continue
|
|
}
|
|
if !allowed[id] {
|
|
return nil, ErrAssigneeNoAccess
|
|
}
|
|
seen[id] = true
|
|
clean = append(clean, id)
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
keep := make([]interface{}, 0, len(clean)+1)
|
|
keep = append(keep, taskID)
|
|
ph := make([]string, 0, len(clean))
|
|
for _, id := range clean {
|
|
keep = append(keep, id)
|
|
ph = append(ph, "?")
|
|
}
|
|
delQuery := `DELETE FROM task_assignees WHERE task_id = ?`
|
|
if len(ph) > 0 {
|
|
delQuery += ` AND user_id NOT IN (` + strings.Join(ph, ",") + `)`
|
|
}
|
|
if _, err := tx.Exec(delQuery, keep...); err != nil {
|
|
return nil, err
|
|
}
|
|
now := domain.NowISO()
|
|
for _, id := range clean {
|
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, id, now); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.getTaskAssignees(taskID), nil
|
|
}
|
|
|
|
// PruneTaskAssignees drops every assignment whose user can no longer see the task's list (share removed,
|
|
// left the team, team deleted, task moved to another list or out of any list). Called after each change
|
|
// that can take access away, so assignments never outlive permissions.
|
|
func (s *Store) PruneTaskAssignees() {
|
|
_, _ = s.db.Exec(
|
|
`DELETE FROM task_assignees WHERE rowid IN (
|
|
SELECT ta.rowid FROM task_assignees ta
|
|
LEFT JOIN thoughts t ON t.id = ta.task_id
|
|
WHERE t.id IS NULL OR t.task_list_id = ''
|
|
OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_id") + `)
|
|
)`,
|
|
)
|
|
}
|
|
|
|
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares and members of shared teams.
|
|
func (s *Store) GetTaskListUserIDs(listID string) []string {
|
|
if listID == "" {
|
|
return []string{}
|
|
}
|
|
return s.queryUserIDs(fmt.Sprintf(taskListAccessSQL, "?"), listID, listID, listID)
|
|
}
|
|
|
|
// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with.
|
|
func (s *Store) GetSharedFolderUserIDs(folderID string) []string {
|
|
if folderID == "" {
|
|
return []string{}
|
|
}
|
|
return s.queryUserIDs(
|
|
`SELECT owner_id FROM workspace_shared_folders WHERE id = ?
|
|
UNION SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user'
|
|
UNION SELECT tm.user_id FROM team_members tm
|
|
WHERE tm.team_id IN (SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'team')`,
|
|
folderID, folderID, folderID,
|
|
)
|
|
}
|
|
|
|
func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember {
|
|
memberMap := make(map[string]domain.TaskListMember)
|
|
|
|
// 1. Owner
|
|
var ownerName, ownerEmail, ownerAvatar string
|
|
_ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar)
|
|
memberMap[tl.UserID] = domain.TaskListMember{
|
|
UserID: tl.UserID,
|
|
DisplayName: ownerName,
|
|
Email: ownerEmail,
|
|
AvatarURL: ownerAvatar,
|
|
Role: "owner",
|
|
}
|
|
|
|
// 2. 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, u.avatar_url 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, &m.AvatarURL); 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, avatar_url FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); 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, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
|
FROM task_lists
|
|
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 = ?))
|
|
)
|
|
ORDER BY updated_at DESC`,
|
|
userID, userID, userID,
|
|
)
|
|
if err != nil {
|
|
return []domain.TaskList{}, err
|
|
}
|
|
|
|
defaultCols := []domain.KanbanColumn{
|
|
{ID: "todo", Name: "Zu erledigen"},
|
|
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
|
|
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
|
|
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
|
|
}
|
|
|
|
list := make([]domain.TaskList, 0)
|
|
for rows.Next() {
|
|
var tl domain.TaskList
|
|
var colsJSON string
|
|
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
|
tl.IsOwner = (tl.UserID == userID)
|
|
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
|
|
if len(tl.Columns) == 0 {
|
|
tl.Columns = defaultCols
|
|
}
|
|
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)
|
|
|
|
shares, _ := s.GetTaskListShares(list[i].ID)
|
|
list[i].Shares = shares
|
|
list[i].Permission = s.GetTaskListPermission(userID, list[i].ID)
|
|
list[i].Members = s.getMembersForTaskList(&list[i])
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
|
|
var tl domain.TaskList
|
|
var colsJSON string
|
|
err := s.db.QueryRow(
|
|
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
|
FROM task_lists
|
|
WHERE id = ? AND (
|
|
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 = ?))
|
|
)
|
|
)`,
|
|
listID, userID, userID, userID,
|
|
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tl.IsOwner = (tl.UserID == userID)
|
|
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
|
|
if len(tl.Columns) == 0 {
|
|
tl.Columns = []domain.KanbanColumn{
|
|
{ID: "todo", Name: "Zu erledigen"},
|
|
{ID: "in_progress", Name: "In Bearbeitung", Color: "accent"},
|
|
{ID: "waiting", Name: "Wartet auf andere", Color: "amber"},
|
|
{ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"},
|
|
}
|
|
}
|
|
_ = 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)
|
|
shares, _ := s.GetTaskListShares(tl.ID)
|
|
tl.Shares = shares
|
|
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
|
|
tl.Members = s.getMembersForTaskList(&tl)
|
|
return &tl, nil
|
|
}
|
|
|
|
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
|
|
tl, err := s.GetTaskListByID(userID, listID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tl.Permission == "viewer" {
|
|
return nil, ErrTaskListReadOnly
|
|
}
|
|
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 columns != nil {
|
|
tl.Columns = *columns
|
|
}
|
|
tl.UpdatedAt = domain.NowISO()
|
|
colsJSON, _ := json.Marshal(tl.Columns)
|
|
_, err = s.db.Exec(
|
|
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
|
|
tl.Title, tl.Icon, tl.Color, tl.SpaceID, string(colsJSON), 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)
|
|
s.PruneTaskAssignees()
|
|
return err
|
|
}
|
|
|
|
// --- ORGANIZATIONS & TEAMS ---
|
|
|
|
func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, errors.New("team name required")
|
|
}
|
|
orgID = strings.TrimSpace(orgID)
|
|
|
|
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)
|
|
s.pruneSharingNow()
|
|
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) GetTeamByID(teamID string) (*domain.Team, error) {
|
|
var t domain.Team
|
|
err := s.db.QueryRow(
|
|
`SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`,
|
|
teamID,
|
|
).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Members, _ = s.GetTeamMembers(t.ID)
|
|
return &t, 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) UpdateTeam(teamID, name, description string) (*domain.Team, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return nil, errors.New("Name für die Gruppe ist erforderlich")
|
|
}
|
|
_, err := s.db.Exec(`UPDATE teams SET name = ?, description = ? WHERE id = ?`, name, strings.TrimSpace(description), teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.GetTeamByID(teamID)
|
|
}
|
|
|
|
func (s *Store) GetTeamMemberRole(teamID, userID string) (string, error) {
|
|
var role string
|
|
err := s.db.QueryRow(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
return role, nil
|
|
}
|
|
|
|
func (s *Store) CountTeamAdmins(teamID string) (int, error) {
|
|
var count int
|
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND role = 'admin'`, teamID).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
func (s *Store) AddTeamMember(teamID, userID, role string) error {
|
|
role = strings.ToLower(strings.TrimSpace(role))
|
|
if role != "admin" && role != "member" {
|
|
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) UpdateTeamMemberRole(teamID, userID, role string) error {
|
|
role = strings.ToLower(strings.TrimSpace(role))
|
|
if role != "admin" && role != "member" {
|
|
return errors.New("Ungültige Rolle (erlaubt sind: admin, member)")
|
|
}
|
|
currentRole, err := s.GetTeamMemberRole(teamID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if currentRole == "" {
|
|
return errors.New("Benutzer ist kein Mitglied dieser Gruppe")
|
|
}
|
|
if currentRole == "admin" && role != "admin" {
|
|
adminCount, err := s.CountTeamAdmins(teamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if adminCount <= 1 {
|
|
return errors.New("Die Gruppe muss mindestens einen Gruppen-Admin behalten")
|
|
}
|
|
}
|
|
_, err = s.db.Exec(`UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?`, role, teamID, userID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) RemoveTeamMember(teamID, userID string) error {
|
|
currentRole, err := s.GetTeamMemberRole(teamID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if currentRole == "admin" {
|
|
adminCount, err := s.CountTeamAdmins(teamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if adminCount <= 1 {
|
|
return errors.New("Du bist der letzte Gruppen-Admin. Bitte ernenne zuerst einen anderen Admin oder lösche die Gruppe.")
|
|
}
|
|
}
|
|
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
|
|
// Whoever leaves a group also loses the shares they made with it
|
|
s.pruneSharingNow()
|
|
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, u.avatar_url, 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.AvatarURL, &m.Role, &m.JoinedAt); err == nil {
|
|
members = append(members, m)
|
|
}
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
// --- USER INVITES ---
|
|
|
|
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
|
|
if role != "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,
|
|
OrganizationID: strings.TrimSpace(orgID),
|
|
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, organization_id, created_by, expires_at, used_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
|
|
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, 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.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
|
|
FROM user_invites i
|
|
LEFT JOIN organizations o ON o.id = i.organization_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.OrganizationID, &inv.OrganizationName, &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, organization_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.OrganizationID, &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, skipVerify 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
|
|
cur.SMTPSkipVerify = skipVerify
|
|
_ = s.UpdateSystemSettings(cur)
|
|
}
|
|
}
|
|
|
|
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
|
settings := &domain.SystemSettings{
|
|
SharingMode: SharingOrganizations,
|
|
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")
|
|
case "smtp_skip_verify":
|
|
settings.SMTPSkipVerify = v == "true"
|
|
case "allow_private_webhooks":
|
|
settings.AllowPrivateWebhooks = v == "true"
|
|
}
|
|
}
|
|
}
|
|
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"
|
|
}
|
|
skipVerifyStr := "false"
|
|
if settings.SMTPSkipVerify {
|
|
skipVerifyStr = "true"
|
|
}
|
|
privateHooksStr := "false"
|
|
if settings.AllowPrivateWebhooks {
|
|
privateHooksStr = "true"
|
|
}
|
|
if settings.SMTPPort == "" {
|
|
settings.SMTPPort = "587"
|
|
}
|
|
settings.SharingMode = NormalizeSharingMode(settings.SharingMode)
|
|
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', ?),
|
|
('smtp_skip_verify', ?),
|
|
('allow_private_webhooks', ?)
|
|
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,
|
|
skipVerifyStr,
|
|
privateHooksStr,
|
|
)
|
|
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
|
|
}
|