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

This commit is contained in:
sandrodach committed 2026-10-24 22:59:48 +02:00
1 parent 50ac0c8f64
commit 7802eb2cf0
17 files changed
+4391 -1368

No files matched your search

+404 -119
View File
@@ -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
}