feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
50ac0c8f64
commit
7802eb2cf0
17 files changed
+4391
-1368
No files matched your search
+404
-119
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
@@ -71,11 +72,26 @@ func (s *Store) migrate() error {
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
alarm_channels TEXT NOT NULL DEFAULT 'discord',
|
||||
alarm_email TEXT NOT NULL DEFAULT '',
|
||||
discord_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
custom_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
notify_before_minutes INTEGER NOT NULL DEFAULT 30,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
team_id TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thoughts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -237,7 +253,10 @@ func (s *Store) migrate() error {
|
||||
columnMigrations := []string{
|
||||
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
|
||||
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
||||
"ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';",
|
||||
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE users ADD COLUMN custom_webhook_url TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE users ADD COLUMN notify_before_minutes INTEGER NOT NULL DEFAULT 30;",
|
||||
|
||||
"ALTER TABLE thoughts ADD COLUMN task_list_id TEXT NOT NULL DEFAULT '';",
|
||||
@@ -251,20 +270,35 @@ func (s *Store) migrate() error {
|
||||
_, _ = s.db.Exec(q)
|
||||
}
|
||||
|
||||
// Default system settings
|
||||
// Remove legacy auto-seeded default user ('admin@mindsift.local') so First-Boot Setup triggers cleanly
|
||||
var legacyUserID string
|
||||
if err := s.db.QueryRow(`SELECT id FROM users WHERE email = 'admin@mindsift.local'`).Scan(&legacyUserID); err == nil && legacyUserID != "" {
|
||||
_, _ = s.db.Exec(`DELETE FROM thoughts WHERE user_id = ?`, legacyUserID)
|
||||
_, _ = s.db.Exec(`DELETE FROM documents WHERE user_id = ?`, legacyUserID)
|
||||
_, _ = s.db.Exec(`DELETE FROM spaces WHERE owner_id = ?`, legacyUserID)
|
||||
_, _ = s.db.Exec(`DELETE FROM task_lists WHERE user_id = ?`, legacyUserID)
|
||||
_, _ = s.db.Exec(`DELETE FROM users WHERE id = ?`, legacyUserID)
|
||||
}
|
||||
|
||||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||
('sharing_mode', 'teams_only'),
|
||||
('allow_registration', 'true'),
|
||||
('instance_name', 'MindSift')`)
|
||||
('allow_registration', 'false'),
|
||||
('instance_name', 'MindSift'),
|
||||
('smtp_host', ''),
|
||||
('smtp_port', '587'),
|
||||
('smtp_user', ''),
|
||||
('smtp_pass', ''),
|
||||
('smtp_from', ''),
|
||||
('smtp_tls', 'true')`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedStarterContent populates realistic initial content for a newly created user
|
||||
// SeedStarterContent populates realistic initial content for a newly created user if requested
|
||||
func (s *Store) SeedStarterContent(user *domain.User) error {
|
||||
now := domain.NowISO()
|
||||
|
||||
// 1. Starter Thoughts in Inbox for Tinder-Style Triage
|
||||
starterThoughts := []struct {
|
||||
content string
|
||||
priority string
|
||||
@@ -286,80 +320,37 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
|
||||
priority: "urgent",
|
||||
source: "webhook",
|
||||
},
|
||||
{
|
||||
content: "Buch-Empfehlung aus dem Podcast: 'The Pragmatic Programmer' nochmal lesen #lesen",
|
||||
priority: "low",
|
||||
source: "manual",
|
||||
},
|
||||
{
|
||||
content: "Steuerunterlagen für Q3 an den Steuerberater schicken #finanzen #büro",
|
||||
priority: "high",
|
||||
source: "manual",
|
||||
},
|
||||
}
|
||||
|
||||
for _, st := range starterThoughts {
|
||||
_, _ = s.CreateThought(user.ID, st.content, st.priority, st.source, st.dueDate)
|
||||
}
|
||||
|
||||
// Create one already kept Task with due date
|
||||
keptTask, err := s.CreateThought(user.ID, "Gitea CI/CD Pipeline für MindSift Container testen #devops #docker", "high", "manual", now[:10])
|
||||
if err == nil && keptTask != nil {
|
||||
_, _ = s.UpdateThoughtTriage(user.ID, keptTask.ID, "keep", "task", "", now[:10], "")
|
||||
}
|
||||
|
||||
// 2. Create Shared Family Space & Shopping List
|
||||
familySpace, err := s.CreateSpace(user.ID, "Familie & Zuhause", "🏡", "Geteilter Bereich für Einkäufe, Haushalt & Urlaubsplanung", false)
|
||||
if err == nil && familySpace != nil {
|
||||
shopList, err := s.CreateList(familySpace.ID, "Wocheneinkauf Supermarkt", "🛒", "shopping")
|
||||
if err == nil && shopList != nil {
|
||||
items := []struct {
|
||||
content string
|
||||
category string
|
||||
quantity string
|
||||
done bool
|
||||
}{
|
||||
{"Hafermilch (Barista Edition)", "Kühlregal", "3x", false},
|
||||
{"San Marzano Dosentomaten", "Konserven", "2 Dosen", false},
|
||||
{"Frischer Basilikum & Knoblauch", "Obst & Gemüse", "1 Bund", false},
|
||||
{"Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", false},
|
||||
{"Spülmaschinentabs", "Drogerie", "1 Packung", true},
|
||||
}
|
||||
for idx, it := range items {
|
||||
created, err := s.CreateListItem(shopList.ID, it.content, it.category, it.quantity, idx)
|
||||
if err == nil && it.done && created != nil {
|
||||
_, _ = s.ToggleListItem(created.ID, true, user.ID, user.DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vacationList, err := s.CreateList(familySpace.ID, "Packliste Südtirol-Trip", "🏔️", "vacation")
|
||||
if err == nil && vacationList != nil {
|
||||
_, _ = s.CreateListItem(vacationList.ID, "Wanderschuhe imprägnieren", "Ausrüstung", "", 0)
|
||||
_, _ = s.CreateListItem(vacationList.ID, "Powerbank & Ladekabel", "Technik", "2x", 1)
|
||||
_, _ = s.CreateListItem(vacationList.ID, "Vignette Österreich online kaufen", "Dokumente", "", 2)
|
||||
_, _ = s.CreateListItem(shopList.ID, "Hafermilch (Barista Edition)", "Kühlregal", "3x", 0)
|
||||
_, _ = s.CreateListItem(shopList.ID, "Espressobohnen (100% Arabica)", "Kaffee & Tee", "1 kg", 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Starter Workspace Document (Notion-Flair)
|
||||
starterBlocks := []domain.DocumentBlock{
|
||||
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran – ganz ohne Datenbank-Ballast."},
|
||||
{ID: "blk_2", Type: "h2", Content: "Wie funktioniert der Workflow?"},
|
||||
{ID: "blk_3", Type: "bullet", Content: "1. Capture First: Drücke jederzeit 'C' oder '/' und tippe oder diktiere deinen Gedanken in unter 3 Sekunden."},
|
||||
{ID: "blk_4", Type: "bullet", Content: "2. Tinder-Style Triage: Wische Karten im Triage-Deck nach links (Verwerfen) oder rechts (Behalten / Aufgabe / Dokument)."},
|
||||
{ID: "blk_5", Type: "bullet", Content: "3. Echtzeit-Listen: Teile deinen Space per Invite-Code mit Partner oder Familie. Abgehakte Einkäufe synchronisieren sich sofort via WebSocket."},
|
||||
{ID: "blk_6", Type: "h2", Content: "Projekt-Checkliste: Homelab Upgrade"},
|
||||
{ID: "blk_7", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true},
|
||||
{ID: "blk_8", Type: "todo", Content: "Home Assistant Webhook für Rauchmelder & Luftfeuchtigkeit anbinden", Checked: false},
|
||||
{ID: "blk_9", Type: "todo", Content: "PWA auf dem Smartphone-Homescreen installieren", Checked: false},
|
||||
{ID: "blk_10", Type: "code", Content: "curl -X POST http://localhost:8080/api/v1/webhooks/ingest?token=DEIN_KEY \\\n -H 'Content-Type: application/json' \\\n -d '{\"title\": \"Waschmaschine im Keller ist fertig!\", \"priority\": \"high\", \"tags\": [\"smarthome\"]}'", Lang: "bash"},
|
||||
{ID: "blk_1", Type: "callout", Content: "Willkommen in deinem MindSift Workspace! Hier wachsen flüchtige Gedanken aus deiner Inbox zu strukturierten Projekten heran."},
|
||||
{ID: "blk_2", Type: "h2", Content: "Projekt-Checkliste"},
|
||||
{ID: "blk_3", Type: "todo", Content: "MindSift als Docker-Container auf eigenem Server starten", Checked: true},
|
||||
}
|
||||
|
||||
_, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Homelab Notizen", "🚀", "", starterBlocks, []string{"homelab", "mindsift", "workflow"}, true)
|
||||
_, _ = s.CreateDocument(user.ID, "", "MindSift Architektur & Notizen", "🚀", "", starterBlocks, []string{"mindsift", "workflow"}, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDefaultUser creates a default user if the database has 0 users so self-hosted users can jump right in
|
||||
// EnsureDefaultUser is retained strictly for unit test convenience
|
||||
func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||||
s.initMu.Lock()
|
||||
defer s.initMu.Unlock()
|
||||
@@ -376,22 +367,39 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := s.CreateUser("admin@mindsift.local", hash, "Sandro", "admin")
|
||||
user, err := s.CreateUser("test@mindsift.local", hash, "Admin", "admin")
|
||||
if err != nil {
|
||||
return s.GetFirstUser()
|
||||
}
|
||||
_ = s.SeedStarterContent(user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// --- USERS ---
|
||||
|
||||
const userSelectCols = `id, email, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at`
|
||||
|
||||
func parseAlarmChannels(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
res := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range parts {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if (p == "email" || p == "discord" || p == "webhook") && !seen[p] {
|
||||
seen[p] = true
|
||||
res = append(res, p)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||||
u := &domain.User{}
|
||||
var isActiveInt int
|
||||
var channelsRaw string
|
||||
err := row.Scan(
|
||||
&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName,
|
||||
&u.Role, &isActiveInt, &u.DiscordWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
|
||||
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
|
||||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -400,6 +408,10 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||||
if u.Role == "" {
|
||||
u.Role = "member"
|
||||
}
|
||||
u.AlarmChannels = parseAlarmChannels(channelsRaw)
|
||||
if u.AlarmEmail == "" {
|
||||
u.AlarmEmail = u.Email
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -410,24 +422,28 @@ func (s *Store) CountUsers() (int, error) {
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
|
||||
if role == "" {
|
||||
if role != "admin" && role != "team_admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
cleanEmail := strings.ToLower(strings.TrimSpace(email))
|
||||
u := &domain.User{
|
||||
ID: domain.NewID("usr"),
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
Email: cleanEmail,
|
||||
PasswordHash: passwordHash,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
AlarmChannels: []string{},
|
||||
AlarmEmail: cleanEmail,
|
||||
DiscordWebhookURL: "",
|
||||
CustomWebhookURL: "",
|
||||
NotifyBeforeMinutes: 30,
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO users (id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, '', 30, ?)`,
|
||||
u.ID, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.CreatedAt,
|
||||
`INSERT INTO users (id, email, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`,
|
||||
u.ID, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -437,8 +453,7 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma
|
||||
|
||||
func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
|
||||
FROM users WHERE email = ?`,
|
||||
`SELECT `+userSelectCols+` FROM users WHERE email = ?`,
|
||||
strings.ToLower(strings.TrimSpace(email)),
|
||||
)
|
||||
return scanUser(row)
|
||||
@@ -446,8 +461,7 @@ func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
|
||||
|
||||
func (s *Store) GetUserByID(id string) (*domain.User, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
|
||||
FROM users WHERE id = ?`,
|
||||
`SELECT `+userSelectCols+` FROM users WHERE id = ?`,
|
||||
id,
|
||||
)
|
||||
return scanUser(row)
|
||||
@@ -455,16 +469,14 @@ func (s *Store) GetUserByID(id string) (*domain.User, error) {
|
||||
|
||||
func (s *Store) GetFirstUser() (*domain.User, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
|
||||
FROM users ORDER BY created_at ASC LIMIT 1`,
|
||||
`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 id, email, password_hash, display_name, role, is_active, discord_webhook_url, notify_before_minutes, created_at
|
||||
FROM users ORDER BY created_at ASC`,
|
||||
`SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -479,7 +491,64 @@ func (s *Store) GetAllUsers() ([]domain.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUser(userID string, displayName, email, role *string, isActive *bool, discordWebhookURL *string, notifyBeforeMinutes *int) (*domain.User, error) {
|
||||
func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
|
||||
viewer, err := s.GetUserByID(viewerID)
|
||||
if err != nil {
|
||||
return []domain.User{}, nil
|
||||
}
|
||||
sys, _ := s.GetSystemSettings()
|
||||
mode := "teams_only"
|
||||
if sys != nil && sys.SharingMode != "" {
|
||||
mode = sys.SharingMode
|
||||
}
|
||||
|
||||
if viewer.Role == "admin" || viewer.Role == "team_admin" || mode == "open" {
|
||||
return s.GetAllUsers()
|
||||
}
|
||||
if mode == "strict_private" {
|
||||
return []domain.User{*viewer}, nil
|
||||
}
|
||||
|
||||
// teams_only: users sharing at least one team or organization with viewer
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
|
||||
SELECT tm2.user_id FROM team_members tm1
|
||||
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
|
||||
WHERE tm1.user_id = ?
|
||||
)`,
|
||||
viewerID, viewerID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.User{*viewer}, nil
|
||||
}
|
||||
users := make([]domain.User, 0)
|
||||
for rows.Next() {
|
||||
if u, err := scanUser(rows); err == nil {
|
||||
users = append(users, *u)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUser(
|
||||
userID string,
|
||||
displayName, email, role *string,
|
||||
isActive *bool,
|
||||
discordWebhookURL *string,
|
||||
notifyBeforeMinutes *int,
|
||||
) (*domain.User, error) {
|
||||
return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserFull(
|
||||
userID string,
|
||||
displayName, email, role *string,
|
||||
isActive *bool,
|
||||
alarmChannels *[]string,
|
||||
alarmEmail, discordWebhookURL, customWebhookURL *string,
|
||||
notifyBeforeMinutes *int,
|
||||
) (*domain.User, error) {
|
||||
u, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -490,15 +559,24 @@ func (s *Store) UpdateUser(userID string, displayName, email, role *string, isAc
|
||||
if email != nil {
|
||||
u.Email = strings.ToLower(strings.TrimSpace(*email))
|
||||
}
|
||||
if role != nil && (*role == "admin" || *role == "member") {
|
||||
if role != nil && (*role == "admin" || *role == "team_admin" || *role == "member") {
|
||||
u.Role = *role
|
||||
}
|
||||
if isActive != nil {
|
||||
u.IsActive = *isActive
|
||||
}
|
||||
if alarmChannels != nil {
|
||||
u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ","))
|
||||
}
|
||||
if alarmEmail != nil {
|
||||
u.AlarmEmail = strings.TrimSpace(*alarmEmail)
|
||||
}
|
||||
if discordWebhookURL != nil {
|
||||
u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL)
|
||||
}
|
||||
if customWebhookURL != nil {
|
||||
u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL)
|
||||
}
|
||||
if notifyBeforeMinutes != nil {
|
||||
u.NotifyBeforeMinutes = *notifyBeforeMinutes
|
||||
}
|
||||
@@ -506,9 +584,10 @@ func (s *Store) UpdateUser(userID string, displayName, email, role *string, isAc
|
||||
if u.IsActive {
|
||||
activeInt = 1
|
||||
}
|
||||
channelsStr := strings.Join(u.AlarmChannels, ",")
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, discord_webhook_url = ?, notify_before_minutes = ? WHERE id = ?`,
|
||||
u.DisplayName, u.Email, u.Role, activeInt, u.DiscordWebhookURL, u.NotifyBeforeMinutes, userID,
|
||||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ? WHERE id = ?`,
|
||||
u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1591,18 +1670,24 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []domain.TaskList{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []domain.TaskList
|
||||
list := make([]domain.TaskList, 0)
|
||||
for rows.Next() {
|
||||
var tl domain.TaskList
|
||||
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
|
||||
list = append(list, tl)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range list {
|
||||
_ = 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)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
@@ -1665,6 +1750,7 @@ func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization,
|
||||
ID: domain.NewID("org"),
|
||||
Name: name,
|
||||
OwnerID: ownerID,
|
||||
Teams: []domain.Team{},
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
@@ -1677,28 +1763,49 @@ func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization,
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT o.id, o.name, o.owner_id, o.created_at
|
||||
FROM organizations o
|
||||
LEFT JOIN teams t ON t.org_id = o.id
|
||||
LEFT JOIN team_members tm ON tm.team_id = t.id
|
||||
WHERE o.owner_id = ? OR tm.user_id = ?
|
||||
ORDER BY o.created_at ASC`,
|
||||
userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
func (s *Store) DeleteOrganization(orgID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID)
|
||||
return err
|
||||
}
|
||||
|
||||
var orgs []domain.Organization
|
||||
func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
|
||||
viewer, _ := s.GetUserByID(userID)
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if viewer != nil && (viewer.Role == "admin" || viewer.Role == "team_admin") {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT DISTINCT o.id, o.name, o.owner_id, o.created_at
|
||||
FROM organizations o
|
||||
LEFT JOIN teams t ON t.org_id = o.id
|
||||
LEFT JOIN team_members tm ON tm.team_id = t.id
|
||||
WHERE o.owner_id = ? OR tm.user_id = ?
|
||||
ORDER BY o.created_at ASC`,
|
||||
userID, userID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return []domain.Organization{}, err
|
||||
}
|
||||
|
||||
orgs := make([]domain.Organization, 0)
|
||||
for rows.Next() {
|
||||
var o domain.Organization
|
||||
if err := rows.Scan(&o.ID, &o.Name, &o.OwnerID, &o.CreatedAt); err == nil {
|
||||
o.Teams = []domain.Team{}
|
||||
orgs = append(orgs, o)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range orgs {
|
||||
teams, _ := s.GetTeamsForOrg(orgs[i].ID)
|
||||
orgs[i].Teams = teams
|
||||
}
|
||||
return orgs, nil
|
||||
}
|
||||
|
||||
@@ -1712,6 +1819,7 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
|
||||
OrgID: orgID,
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(description),
|
||||
Members: []domain.TeamMember{},
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
@@ -1724,24 +1832,34 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
|
||||
return team, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTeam(teamID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`,
|
||||
orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []domain.Team{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var teams []domain.Team
|
||||
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, _ = s.GetTeamMembers(t.ID)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1755,24 +1873,29 @@ func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []domain.Team{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var teams []domain.Team
|
||||
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, _ = s.GetTeamMembers(t.ID)
|
||||
t.Members = []domain.TeamMember{}
|
||||
teams = append(teams, t)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for i := range teams {
|
||||
members, _ := s.GetTeamMembers(teams[i].ID)
|
||||
teams[i].Members = members
|
||||
}
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
func (s *Store) AddTeamMember(teamID, userID, role string) error {
|
||||
if role == "" {
|
||||
role = "editor"
|
||||
role = "member"
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
|
||||
@@ -1797,11 +1920,11 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
||||
teamID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []domain.TeamMember{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []domain.TeamMember
|
||||
members := make([]domain.TeamMember, 0)
|
||||
for rows.Next() {
|
||||
var m domain.TeamMember
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.Role, &m.JoinedAt); err == nil {
|
||||
@@ -1811,13 +1934,118 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// --- USER INVITES ---
|
||||
|
||||
func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDays int) (*domain.UserInvite, error) {
|
||||
if role != "admin" && role != "team_admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
if validDays <= 0 || validDays > 365 {
|
||||
validDays = 7
|
||||
}
|
||||
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
|
||||
now := time.Now().UTC()
|
||||
inv := &domain.UserInvite{
|
||||
ID: domain.NewID("inv"),
|
||||
Code: code,
|
||||
Email: strings.ToLower(strings.TrimSpace(email)),
|
||||
Role: role,
|
||||
TeamID: strings.TrimSpace(teamID),
|
||||
CreatedBy: createdBy,
|
||||
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO user_invites (id, code, email, role, team_id, created_by, expires_at, used_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
|
||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.TeamID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT i.id, i.code, i.email, i.role, i.team_id, COALESCE(t.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
|
||||
FROM user_invites i
|
||||
LEFT JOIN teams t ON t.id = i.team_id
|
||||
ORDER BY i.created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.UserInvite{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
invites := make([]domain.UserInvite, 0)
|
||||
for rows.Next() {
|
||||
var inv domain.UserInvite
|
||||
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.TeamName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
|
||||
invites = append(invites, inv)
|
||||
}
|
||||
}
|
||||
return invites, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
var inv domain.UserInvite
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, code, email, role, team_id, created_by, expires_at, used_by, created_at
|
||||
FROM user_invites WHERE UPPER(code) = ?`,
|
||||
code,
|
||||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, errors.New("Ungültiger Einladungscode")
|
||||
}
|
||||
if inv.UsedBy != "" {
|
||||
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
|
||||
}
|
||||
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
|
||||
return nil, errors.New("Dieser Einladungscode ist abgelaufen")
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
|
||||
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserInvite(inviteID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- SYSTEM SETTINGS ---
|
||||
|
||||
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls bool) {
|
||||
cur, err := s.GetSystemSettings()
|
||||
if err != nil || cur == nil {
|
||||
return
|
||||
}
|
||||
// Only populate DB from env if DB has no SMTP host configured yet and env provides one
|
||||
if cur.SMTPHost == "" && strings.TrimSpace(host) != "" {
|
||||
cur.SMTPHost = strings.TrimSpace(host)
|
||||
if strings.TrimSpace(port) != "" {
|
||||
cur.SMTPPort = strings.TrimSpace(port)
|
||||
}
|
||||
cur.SMTPUser = strings.TrimSpace(user)
|
||||
cur.SMTPPass = pass
|
||||
cur.SMTPFrom = strings.TrimSpace(from)
|
||||
cur.SMTPTLS = tls
|
||||
_ = s.UpdateSystemSettings(cur)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
settings := &domain.SystemSettings{
|
||||
SharingMode: "teams_only",
|
||||
AllowRegistration: true,
|
||||
AllowRegistration: false,
|
||||
InstanceName: "MindSift",
|
||||
SMTPPort: "587",
|
||||
SMTPTLS: true,
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`SELECT key, value FROM system_settings`)
|
||||
@@ -1836,6 +2064,20 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1847,12 +2089,39 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
if settings.AllowRegistration {
|
||||
regStr = "true"
|
||||
}
|
||||
tlsStr := "false"
|
||||
if settings.SMTPTLS {
|
||||
tlsStr = "true"
|
||||
}
|
||||
if settings.SMTPPort == "" {
|
||||
settings.SMTPPort = "587"
|
||||
}
|
||||
if settings.SharingMode == "" {
|
||||
settings.SharingMode = "teams_only"
|
||||
}
|
||||
if settings.InstanceName == "" {
|
||||
settings.InstanceName = "MindSift"
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES
|
||||
('sharing_mode', ?),
|
||||
('allow_registration', ?),
|
||||
('instance_name', ?)
|
||||
('instance_name', ?),
|
||||
('smtp_host', ?),
|
||||
('smtp_port', ?),
|
||||
('smtp_user', ?),
|
||||
('smtp_pass', ?),
|
||||
('smtp_from', ?),
|
||||
('smtp_tls', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
settings.SharingMode, regStr, settings.InstanceName,
|
||||
settings.SharingMode,
|
||||
regStr,
|
||||
settings.InstanceName,
|
||||
strings.TrimSpace(settings.SMTPHost),
|
||||
strings.TrimSpace(settings.SMTPPort),
|
||||
strings.TrimSpace(settings.SMTPUser),
|
||||
settings.SMTPPass,
|
||||
strings.TrimSpace(settings.SMTPFrom),
|
||||
tlsStr,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -1860,34 +2129,49 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
// --- ALARMS ---
|
||||
|
||||
type DueTaskAlarm struct {
|
||||
TaskID string
|
||||
UserID string
|
||||
Title string
|
||||
DueDate string
|
||||
Priority string
|
||||
DiscordWebhookURL string
|
||||
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.due_date, t.priority, u.discord_webhook_url
|
||||
`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.discord_webhook_url != ''`,
|
||||
AND u.is_active = 1`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []DueTaskAlarm{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var alarms []DueTaskAlarm
|
||||
alarms := make([]DueTaskAlarm, 0)
|
||||
for rows.Next() {
|
||||
var a DueTaskAlarm
|
||||
if err := rows.Scan(&a.TaskID, &a.UserID, &a.Title, &a.DueDate, &a.Priority, &a.DiscordWebhookURL); err == nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1898,3 +2182,4 @@ func (s *Store) MarkTaskAlarmSent(taskID string) error {
|
||||
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user