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
+602
-81
@@ -2,10 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,7 +35,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
|
||||
wsHub: wsHub,
|
||||
staticFS: staticFS,
|
||||
}
|
||||
go srv.startDiscordAlarmTicker()
|
||||
go srv.startAlarmTicker()
|
||||
return srv
|
||||
}
|
||||
|
||||
@@ -51,8 +53,10 @@ func (s *Server) Router() http.Handler {
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Public Health & Webhook Routes
|
||||
// Public Health, Setup & Auth Routes
|
||||
r.Get("/api/v1/health", s.handleHealth)
|
||||
r.Get("/api/v1/auth/status", s.handleAuthStatus)
|
||||
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
|
||||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||||
@@ -65,6 +69,8 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
r.Get("/api/v1/auth/me", s.handleMe)
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||||
r.Get("/api/v1/stats", s.handleStats)
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
@@ -111,9 +117,11 @@ func (s *Server) Router() http.Handler {
|
||||
// Organizations & Teams
|
||||
r.Get("/api/v1/organizations", s.handleGetOrganizations)
|
||||
r.Post("/api/v1/organizations", s.handleCreateOrganization)
|
||||
r.Delete("/api/v1/organizations/{id}", s.handleDeleteOrganization)
|
||||
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
|
||||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
||||
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
|
||||
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
|
||||
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||||
|
||||
@@ -122,16 +130,26 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||||
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
|
||||
|
||||
// Admin Panel Routes
|
||||
// Staff Routes (Server-Admin & Team-Admin)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Use(s.staffMiddleware)
|
||||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||||
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
|
||||
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
|
||||
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
|
||||
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
|
||||
|
||||
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||||
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
|
||||
})
|
||||
|
||||
// Server-Admin Strictly Protected Routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||||
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,12 +162,10 @@ func (s *Server) Router() http.Handler {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(s.staticFS, path); err != nil {
|
||||
// Never return index.html for missing JS/CSS assets
|
||||
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
// SPA client-side routing fallback to index.html
|
||||
req.URL.Path = "/"
|
||||
path = "index.html"
|
||||
}
|
||||
@@ -186,21 +202,18 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if strings.HasPrefix(tokenStr, "mindsift_sec_") {
|
||||
user, err := s.store.ValidateAPIKeyAndGetUser(tokenStr)
|
||||
if err == nil && user != nil {
|
||||
if err == nil && user != nil && user.IsActive {
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
return
|
||||
}
|
||||
}
|
||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
|
||||
if err == nil && claims != nil {
|
||||
userID := claims.UserID
|
||||
if _, userErr := s.store.GetUserByID(userID); userErr != nil {
|
||||
if defUser, defErr := s.store.EnsureDefaultUser(); defErr == nil && defUser != nil {
|
||||
userID = defUser.ID
|
||||
}
|
||||
user, userErr := s.store.GetUserByID(claims.UserID)
|
||||
if userErr == nil && user != nil && user.IsActive {
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), userID)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,13 +221,29 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey != "" {
|
||||
user, err := s.store.ValidateAPIKeyAndGetUser(apiKey)
|
||||
if err == nil && user != nil {
|
||||
if err == nil && user != nil && user.IsActive {
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte einloggen")
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) staffMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
return
|
||||
}
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil || (user.Role != "admin" && user.Role != "team_admin") {
|
||||
writeError(w, http.StatusForbidden, "Admin- oder Team-Admin-Berechtigung erforderlich")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,7 +256,7 @@ func (s *Server) adminMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil || user.Role != "admin" {
|
||||
writeError(w, http.StatusForbidden, "Administrator-Rechte erforderlich")
|
||||
writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -244,20 +273,98 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
sysSettings, _ := s.store.GetSystemSettings()
|
||||
if !s.cfg.AllowRegistration || (sysSettings != nil && !sysSettings.AllowRegistration) {
|
||||
count, _ := s.store.CountUsers()
|
||||
if count > 0 {
|
||||
writeError(w, http.StatusForbidden, "Registrierung ist auf dieser Instanz deaktiviert")
|
||||
return
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
count, _ := s.store.CountUsers()
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
allowReg := false
|
||||
instanceName := "MindSift"
|
||||
if sys != nil {
|
||||
allowReg = sys.AllowRegistration
|
||||
if sys.InstanceName != "" {
|
||||
instanceName = sys.InstanceName
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"needsSetup": count == 0,
|
||||
"allowRegistration": allowReg,
|
||||
"instanceName": instanceName,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := s.store.CountUsers()
|
||||
if err != nil || count > 0 {
|
||||
writeError(w, http.StatusForbidden, "Ersteinrichtung wurde bereits abgeschlossen")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
SharingMode string `json:"sharingMode"`
|
||||
SeedSampleData bool `json:"seedSampleData"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.DisplayName) == "" {
|
||||
req.DisplayName = "Administrator"
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
if sys != nil {
|
||||
if strings.TrimSpace(req.InstanceName) != "" {
|
||||
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
||||
}
|
||||
sys.AllowRegistration = req.AllowRegistration
|
||||
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
|
||||
sys.SharingMode = req.SharingMode
|
||||
}
|
||||
_ = s.store.UpdateSystemSettings(sys)
|
||||
}
|
||||
|
||||
if req.SeedSampleData {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
@@ -271,25 +378,50 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
req.DisplayName = strings.Split(req.Email, "@")[0]
|
||||
}
|
||||
|
||||
count, _ := s.store.CountUsers()
|
||||
role := "member"
|
||||
var validInvite *domain.UserInvite
|
||||
|
||||
if strings.TrimSpace(req.InviteCode) != "" {
|
||||
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if inv.Email != "" && !strings.EqualFold(inv.Email, strings.TrimSpace(req.Email)) {
|
||||
writeError(w, http.StatusForbidden, "Dieser Einladungscode ist an eine andere E-Mail-Adresse gebunden")
|
||||
return
|
||||
}
|
||||
validInvite = inv
|
||||
role = inv.Role
|
||||
} else if count == 0 {
|
||||
role = "admin"
|
||||
} else {
|
||||
sysSettings, _ := s.store.GetSystemSettings()
|
||||
if sysSettings == nil || !sysSettings.AllowRegistration {
|
||||
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
|
||||
return
|
||||
}
|
||||
|
||||
count, _ := s.store.CountUsers()
|
||||
role := "member"
|
||||
if count == 0 {
|
||||
role = "admin"
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
if validInvite != nil {
|
||||
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
|
||||
if validInvite.TeamID != "" {
|
||||
_ = s.store.AddTeamMember(validInvite.TeamID, user.ID, "member")
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
@@ -336,22 +468,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.store.EnsureDefaultUser()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Standard-Profil konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token-Fehler")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
writeError(w, http.StatusForbidden, "Automatischer Gast-Login ist deaktiviert – bitte anmelden")
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -984,16 +1101,19 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, exportData)
|
||||
}
|
||||
|
||||
// --- USER SETTINGS ---
|
||||
// --- USER SETTINGS & ALARMS ---
|
||||
|
||||
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
DisplayName *string `json:"displayName"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"`
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
DisplayName *string `json:"displayName"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"`
|
||||
AlarmChannels *[]string `json:"alarmChannels"`
|
||||
AlarmEmail *string `json:"alarmEmail"`
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL *string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -1007,7 +1127,18 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
_ = s.store.UpdateUserPassword(userID, hash)
|
||||
}
|
||||
u, err := s.store.UpdateUser(userID, req.DisplayName, req.Email, nil, nil, req.DiscordWebhookURL, req.NotifyBeforeMinutes)
|
||||
u, err := s.store.UpdateUserFull(
|
||||
userID,
|
||||
req.DisplayName,
|
||||
req.Email,
|
||||
nil,
|
||||
nil,
|
||||
req.AlarmChannels,
|
||||
req.AlarmEmail,
|
||||
req.DiscordWebhookURL,
|
||||
req.CustomWebhookURL,
|
||||
req.NotifyBeforeMinutes,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -1015,6 +1146,86 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Channel string `json:"channel"` // email | discord | webhook
|
||||
Target string `json:"target"` // optional override URL or email
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
channel := strings.ToLower(strings.TrimSpace(req.Channel))
|
||||
target := strings.TrimSpace(req.Target)
|
||||
|
||||
switch channel {
|
||||
case "discord":
|
||||
url := target
|
||||
if url == "" {
|
||||
url = user.DiscordWebhookURL
|
||||
}
|
||||
if url == "" {
|
||||
writeError(w, http.StatusBadRequest, "Bitte eine Discord Webhook URL angeben")
|
||||
return
|
||||
}
|
||||
if err := s.sendDiscordNotification(url, "Test-Alarm von MindSift", "Dein Discord-Alarmkanal funktioniert einwandfrei!", domain.NowISO()[:10], "normal"); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Discord-Fehler: "+err.Error())
|
||||
return
|
||||
}
|
||||
case "webhook":
|
||||
url := target
|
||||
if url == "" {
|
||||
url = user.CustomWebhookURL
|
||||
}
|
||||
if url == "" {
|
||||
writeError(w, http.StatusBadRequest, "Bitte eine Webhook-URL angeben")
|
||||
return
|
||||
}
|
||||
if err := s.sendCustomWebhookNotification(url, "test_alarm", "Test-Alarm von MindSift", "Dein eigener Webhook empfängt MindSift-Alarme!", domain.NowISO()[:10], "normal"); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Webhook-Fehler: "+err.Error())
|
||||
return
|
||||
}
|
||||
case "email":
|
||||
toEmail := target
|
||||
if toEmail == "" {
|
||||
toEmail = user.AlarmEmail
|
||||
}
|
||||
if toEmail == "" {
|
||||
toEmail = user.Email
|
||||
}
|
||||
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "Unbekannter Alarm-Kanal (erlaubt: email, discord, webhook)")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"sent": true,
|
||||
"channel": channel,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
users, err := s.store.GetDiscoverableUsers(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// --- TASK LISTS ---
|
||||
|
||||
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1107,6 +1318,15 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusCreated, org)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
if err := s.store.DeleteOrganization(orgID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
teams, err := s.store.GetTeamsForOrg(orgID)
|
||||
@@ -1118,6 +1338,7 @@ func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
orgID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
@@ -1132,9 +1353,21 @@ func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Automatically add creator to team
|
||||
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
|
||||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||
writeJSON(w, http.StatusCreated, team)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
|
||||
teamID := chi.URLParam(r, "id")
|
||||
if err := s.store.DeleteTeam(teamID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
teams, err := s.store.GetTeamsForUser(userID)
|
||||
@@ -1149,17 +1382,32 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
teamID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddTeamMember(teamID, req.UserID, req.Role); err != nil {
|
||||
targetUserID := strings.TrimSpace(req.UserID)
|
||||
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
|
||||
u, err := s.store.GetUserByEmail(req.Email)
|
||||
if err != nil || u == nil {
|
||||
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
|
||||
return
|
||||
}
|
||||
targetUserID = u.ID
|
||||
}
|
||||
if targetUserID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddTeamMember(teamID, targetUserID, req.Role); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"added": true})
|
||||
members, _ := s.store.GetTeamMembers(teamID)
|
||||
writeJSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1172,7 +1420,7 @@ func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
||||
}
|
||||
|
||||
// --- ADMIN PANEL ---
|
||||
// --- ADMIN & STAFF PANEL ---
|
||||
|
||||
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.store.GetAllUsers()
|
||||
@@ -1184,6 +1432,9 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
callerID := auth.GetUserID(r.Context())
|
||||
caller, _ := s.store.GetUserByID(callerID)
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
@@ -1198,6 +1449,14 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
|
||||
return
|
||||
}
|
||||
// Only full server admin can create another server admin
|
||||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||||
req.Role = "member"
|
||||
}
|
||||
if strings.TrimSpace(req.DisplayName) == "" {
|
||||
req.DisplayName = strings.Split(req.Email, "@")[0]
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||||
@@ -1212,7 +1471,21 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
callerID := auth.GetUserID(r.Context())
|
||||
caller, _ := s.store.GetUserByID(callerID)
|
||||
targetUserID := chi.URLParam(r, "id")
|
||||
targetUser, err := s.store.GetUserByID(targetUserID)
|
||||
if err != nil || targetUser == nil {
|
||||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
// Team-Admins cannot modify Server-Admins
|
||||
if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" {
|
||||
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DisplayName *string `json:"displayName"`
|
||||
Email *string `json:"email"`
|
||||
@@ -1224,6 +1497,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||||
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
@@ -1233,7 +1511,15 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
callerID := auth.GetUserID(r.Context())
|
||||
caller, _ := s.store.GetUserByID(callerID)
|
||||
targetUserID := chi.URLParam(r, "id")
|
||||
targetUser, _ := s.store.GetUserByID(targetUserID)
|
||||
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
|
||||
writeError(w, http.StatusForbidden, "Keine Berechtigung")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
@@ -1260,6 +1546,12 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
|
||||
return
|
||||
}
|
||||
caller, _ := s.store.GetUserByID(currUserID)
|
||||
targetUser, _ := s.store.GetUserByID(targetUserID)
|
||||
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
|
||||
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteUser(targetUserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -1267,12 +1559,60 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// --- INVITES MANAGEMENT ---
|
||||
|
||||
func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) {
|
||||
invites, err := s.store.GetUserInvites()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, invites)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
callerID := auth.GetUserID(r.Context())
|
||||
caller, _ := s.store.GetUserByID(callerID)
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
TeamID string `json:"teamId"`
|
||||
ValidDays int `json:"validDays"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||||
req.Role = "member"
|
||||
}
|
||||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.TeamID, req.ValidDays)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, inv)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
inviteID := chi.URLParam(r, "id")
|
||||
if err := s.store.DeleteUserInvite(inviteID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// --- SERVER SETTINGS & SMTP ---
|
||||
|
||||
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.store.GetSystemSettings()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
@@ -1286,17 +1626,180 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// --- DISCORD ALARMS BACKGROUND SCHEDULER ---
|
||||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
|
||||
func (s *Server) startDiscordAlarmTicker() {
|
||||
var req struct {
|
||||
ToEmail string `json:"toEmail"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
toEmail := strings.TrimSpace(req.ToEmail)
|
||||
if toEmail == "" && user != nil {
|
||||
toEmail = user.Email
|
||||
}
|
||||
if toEmail == "" {
|
||||
writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"sent": true,
|
||||
"toEmail": toEmail,
|
||||
})
|
||||
}
|
||||
|
||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||
|
||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
host := ""
|
||||
port := "587"
|
||||
user := ""
|
||||
pass := ""
|
||||
from := ""
|
||||
useTLS := true
|
||||
|
||||
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
|
||||
host = strings.TrimSpace(sys.SMTPHost)
|
||||
port = strings.TrimSpace(sys.SMTPPort)
|
||||
user = strings.TrimSpace(sys.SMTPUser)
|
||||
pass = sys.SMTPPass
|
||||
from = strings.TrimSpace(sys.SMTPFrom)
|
||||
useTLS = sys.SMTPTLS
|
||||
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
|
||||
host = strings.TrimSpace(s.cfg.SMTPHost)
|
||||
port = strings.TrimSpace(s.cfg.SMTPPort)
|
||||
user = strings.TrimSpace(s.cfg.SMTPUser)
|
||||
pass = s.cfg.SMTPPass
|
||||
from = strings.TrimSpace(s.cfg.SMTPFrom)
|
||||
useTLS = s.cfg.SMTPTLS
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
|
||||
}
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
if from == "" {
|
||||
if user != "" && strings.Contains(user, "@") {
|
||||
from = user
|
||||
} else {
|
||||
from = "noreply@mindsift.local"
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%s", host, port)
|
||||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
|
||||
|
||||
var smtpAuth smtp.Auth
|
||||
if user != "" || pass != "" {
|
||||
smtpAuth = smtp.PlainAuth("", user, pass, host)
|
||||
}
|
||||
|
||||
if port == "465" && useTLS {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Quit()
|
||||
if smtpAuth != nil {
|
||||
if err := client.Auth(smtpAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(toEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = wc.Write([]byte(msg))
|
||||
_ = wc.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
|
||||
}
|
||||
|
||||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
payload := map[string]interface{}{
|
||||
"content": "⏰ **MindSift Aufgaben-Alarm**",
|
||||
"embeds": []map[string]interface{}{
|
||||
{
|
||||
"title": title,
|
||||
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
|
||||
"color": 2450411,
|
||||
"footer": map[string]string{
|
||||
"text": "MindSift Alarm-System",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
payload := map[string]interface{}{
|
||||
"event": event,
|
||||
"title": title,
|
||||
"notes": notes,
|
||||
"dueDate": dueDate,
|
||||
"priority": priority,
|
||||
"timestamp": domain.NowISO(),
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) startAlarmTicker() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
for range ticker.C {
|
||||
alarms, err := s.store.GetDueTasksForAlarm()
|
||||
if err != nil || len(alarms) == 0 {
|
||||
@@ -1306,29 +1809,47 @@ func (s *Server) startDiscordAlarmTicker() {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
for _, a := range alarms {
|
||||
if a.DueDate <= today && a.DiscordWebhookURL != "" {
|
||||
payload := map[string]interface{}{
|
||||
"content": fmt.Sprintf("⏰ **MindSift Aufgaben-Erinnerung**"),
|
||||
"embeds": []map[string]interface{}{
|
||||
{
|
||||
"title": a.Title,
|
||||
"description": fmt.Sprintf("Diese Aufgabe ist fällig am **%s** (Priorität: %s)", a.DueDate, a.Priority),
|
||||
"color": 3066993,
|
||||
"footer": map[string]string{
|
||||
"text": "MindSift Reminder",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, mErr := json.Marshal(payload)
|
||||
if mErr == nil {
|
||||
resp, pErr := httpClient.Post(a.DiscordWebhookURL, "application/json", bytes.NewReader(body))
|
||||
if pErr == nil {
|
||||
_ = resp.Body.Close()
|
||||
_ = s.store.MarkTaskAlarmSent(a.TaskID)
|
||||
if a.DueDate > today {
|
||||
continue
|
||||
}
|
||||
if len(a.AlarmChannels) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sentAny := false
|
||||
desc := a.Notes
|
||||
if desc == "" {
|
||||
desc = "Aufgabe ist jetzt fällig."
|
||||
}
|
||||
|
||||
for _, ch := range a.AlarmChannels {
|
||||
switch ch {
|
||||
case "discord":
|
||||
if a.DiscordWebhookURL != "" {
|
||||
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
case "webhook":
|
||||
if a.CustomWebhookURL != "" {
|
||||
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
case "email":
|
||||
if a.AlarmEmail != "" {
|
||||
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, a.DueDate, a.Priority, desc)
|
||||
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sentAny {
|
||||
_ = s.store.MarkTaskAlarmSent(a.TaskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+162
-18
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -29,14 +30,15 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
|
||||
user, err := store.EnsureDefaultUser()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to ensure default user: %v", err)
|
||||
t.Fatalf("failed to ensure test user: %v", err)
|
||||
}
|
||||
_ = store.SeedStarterContent(user)
|
||||
|
||||
cfg := &config.Config{
|
||||
Port: "8080",
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: true,
|
||||
AllowRegistration: false,
|
||||
}
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
@@ -49,6 +51,137 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
return srv, store, token
|
||||
}
|
||||
|
||||
func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create fresh store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Port: "8080",
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: false,
|
||||
}
|
||||
srv := NewServer(cfg, store, ws.NewHub(), nil)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Fresh DB must report needsSetup = true
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code)
|
||||
}
|
||||
var statusResp struct {
|
||||
NeedsSetup bool `json:"needsSetup"`
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
|
||||
if !statusResp.NeedsSetup {
|
||||
t.Fatalf("expected needsSetup=true on fresh database, got false")
|
||||
}
|
||||
|
||||
// 2. Complete First-Boot Setup (without sample data)
|
||||
setupBody := []byte(`{
|
||||
"email": "owner@example.com",
|
||||
"password": "secretpassword",
|
||||
"displayName": "Server Owner",
|
||||
"instanceName": "My MindSift",
|
||||
"allowRegistration": false,
|
||||
"sharingMode": "teams_only",
|
||||
"seedSampleData": false
|
||||
}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from /api/v1/auth/setup, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var setupRes struct {
|
||||
Token string `json:"token"`
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &setupRes)
|
||||
if setupRes.User.Role != "admin" || setupRes.Token == "" {
|
||||
t.Fatalf("expected admin user and token, got %+v", setupRes)
|
||||
}
|
||||
|
||||
// 3. Verify empty endpoints return [] and never null (preventing React crashes)
|
||||
arrayEndpoints := []string{
|
||||
"/api/v1/task-lists",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/organizations",
|
||||
"/api/v1/teams",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/lists",
|
||||
}
|
||||
for _, ep := range arrayEndpoints {
|
||||
req = httptest.NewRequest(http.MethodGet, ep, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s returned %d", ep, rec.Code)
|
||||
}
|
||||
trimmed := strings.TrimSpace(rec.Body.String())
|
||||
if trimmed == "null" || !strings.HasPrefix(trimmed, "[") {
|
||||
t.Fatalf("GET %s returned %q instead of JSON array []", ep, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Public registration without invite must be rejected (403)
|
||||
regBody := []byte(`{"email":"member@example.com","password":"password123","displayName":"Member"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 Forbidden when registration is disabled, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 5. Create an Invite as Admin and register with that Invite Code
|
||||
invBody := []byte(`{"role":"team_admin","validDays":7}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
|
||||
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created for invite, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdInvite domain.UserInvite
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdInvite)
|
||||
if createdInvite.Code == "" {
|
||||
t.Fatalf("expected invite code, got empty")
|
||||
}
|
||||
|
||||
regWithInviteBody, _ := json.Marshal(map[string]string{
|
||||
"email": "invited@example.com",
|
||||
"password": "password123",
|
||||
"displayName": "Invited Team Admin",
|
||||
"inviteCode": createdInvite.Code,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created when registering with valid invite code, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var invitedRes struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
|
||||
if invitedRes.User.Role != "team_admin" {
|
||||
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
@@ -81,27 +214,31 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
// Generate a stale token from a previous container run (non-existent user ID)
|
||||
staleToken, err := auth.GenerateToken("test-jwt-secret-key", "usr_stale_from_old_container", "admin@mindsift.local", "Sandro")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate stale token: %v", err)
|
||||
// Verify stale token from non-existent user is rejected (401)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for stale token, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 1. Capture Quick Dump (default target: inbox) using stale token
|
||||
// 1. Capture Quick Dump (default target: inbox)
|
||||
dumpPayload := map[string]string{
|
||||
"content": "Schneller Quick Dump Testgedanke #test",
|
||||
"priority": "high",
|
||||
"target": "inbox",
|
||||
}
|
||||
body, _ := json.Marshal(dumpPayload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
@@ -125,7 +262,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
}
|
||||
body, _ = json.Marshal(taskPayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -154,7 +291,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
}
|
||||
body, _ = json.Marshal(listPayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -175,7 +312,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
}
|
||||
body, _ = json.Marshal(triagePayload)
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -219,10 +356,13 @@ func TestNewFullFeatures(t *testing.T) {
|
||||
t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 2. Test User Settings: Update Discord Webhook & Profile
|
||||
// 2. Test User Settings: Update Multi-Channel Alarms & Profile
|
||||
settingsPayload := map[string]interface{}{
|
||||
"displayName": "Sandro D.",
|
||||
"displayName": "Admin User",
|
||||
"alarmChannels": []string{"email", "discord", "webhook"},
|
||||
"alarmEmail": "alarms@example.com",
|
||||
"discordWebhookUrl": "https://discord.com/api/webhooks/test/123",
|
||||
"customWebhookUrl": "https://ntfy.sh/my-topic",
|
||||
"notifyBeforeMinutes": 15,
|
||||
}
|
||||
body, _ = json.Marshal(settingsPayload)
|
||||
@@ -235,6 +375,11 @@ func TestNewFullFeatures(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for PATCH /api/v1/auth/settings, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var updatedUser domain.User
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &updatedUser)
|
||||
if len(updatedUser.AlarmChannels) != 3 || updatedUser.AlarmEmail != "alarms@example.com" {
|
||||
t.Fatalf("expected 3 alarm channels and alarmEmail, got %+v", updatedUser)
|
||||
}
|
||||
|
||||
// 3. Test Admin routes (default user is admin)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil)
|
||||
@@ -261,7 +406,6 @@ func TestNewFullFeatures(t *testing.T) {
|
||||
"notes": "Detailnotizen für die Aufgabe",
|
||||
"taskListId": createdTaskList.ID,
|
||||
}
|
||||
body, _ = json.Marshal(taskUpdatePayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader([]byte(`{"content":"Kanban Task Test","target":"task"}`)))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -14,6 +14,14 @@ type Config struct {
|
||||
BaseURL string
|
||||
AllowRegistration bool
|
||||
LogLevel string
|
||||
|
||||
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
SMTPFrom string
|
||||
SMTPTLS bool
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
@@ -30,9 +38,16 @@ func Load() *Config {
|
||||
|
||||
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
|
||||
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
|
||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "true")) != "false"
|
||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
||||
logLevel := getEnv("LOG_LEVEL", "info")
|
||||
|
||||
smtpHost := getEnv("SMTP_HOST", "")
|
||||
smtpPort := getEnv("SMTP_PORT", "587")
|
||||
smtpUser := getEnv("SMTP_USER", "")
|
||||
smtpPass := getEnv("SMTP_PASS", "")
|
||||
smtpFrom := getEnv("SMTP_FROM", "")
|
||||
smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false"
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DataDir: dataDir,
|
||||
@@ -41,6 +56,12 @@ func Load() *Config {
|
||||
BaseURL: baseURL,
|
||||
AllowRegistration: allowReg,
|
||||
LogLevel: logLevel,
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
SMTPPass: smtpPass,
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPTLS: smtpTLS,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-15
@@ -41,15 +41,31 @@ func ExtractTags(content string) []string {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"` // admin | member
|
||||
IsActive bool `json:"isActive"`
|
||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"` // admin | team_admin | member
|
||||
IsActive bool `json:"isActive"`
|
||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||
AlarmEmail string `json:"alarmEmail"`
|
||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // member | team_admin | admin
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
UsedBy string `json:"usedBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ThoughtStatus string
|
||||
@@ -97,6 +113,7 @@ type Organization struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Teams []Team `json:"teams,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -105,7 +122,7 @@ type Team struct {
|
||||
OrgID string `json:"orgId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Members []TeamMember `json:"members,omitempty"`
|
||||
Members []TeamMember `json:"members"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -114,14 +131,21 @@ type TeamMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | editor | viewer
|
||||
Role string `json:"role"` // team_admin | member | owner
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
type SystemSettings struct {
|
||||
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
SMTPFrom string `json:"smtpFrom"`
|
||||
SMTPTLS bool `json:"smtpTls"`
|
||||
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
|
||||
}
|
||||
|
||||
type DocumentBlock struct {
|
||||
@@ -165,7 +189,7 @@ type Space struct {
|
||||
InviteCode string `json:"inviteCode"`
|
||||
IsPersonal bool `json:"isPersonal"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Members []SpaceMember `json:"members,omitempty"`
|
||||
Members []SpaceMember `json:"members"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
+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