Files
MindSift/internal/api/server.go
T
sandrodach 7c47f245e2
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
feat(sharing): remove code redeem, unify Teilen button and team/user sharing across lists and notes
2026-10-25 13:51:42 +01:00

2352 lines
73 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"net/smtp"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
type Server struct {
cfg *config.Config
store *storage.Store
wsHub *ws.Hub
staticFS fs.FS
}
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
srv := &Server{
cfg: cfg,
store: store,
wsHub: wsHub,
staticFS: staticFS,
}
go srv.startAlarmTicker()
return srv
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// 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)
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
r.Get("/api/v1/ws", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
// Authenticated API Routes
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
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/collaborators", s.handleGetCollaborators)
r.Get("/api/v1/stats", s.handleStats)
r.Get("/api/v1/search", s.handleSearch)
r.Get("/api/v1/export", s.handleExport)
// Instant Capture & Thoughts
r.Post("/api/v1/inbox", s.handleCaptureInbox)
r.Get("/api/v1/thoughts", s.handleGetThoughts)
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded)
// Tinder-Style Triage & Tasks
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
r.Get("/api/v1/tasks", s.handleGetTasks)
// Task Lists & Shares
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
r.Post("/api/v1/task-lists", s.handleCreateTaskList)
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
r.Get("/api/v1/task-lists/{id}/shares", s.handleGetTaskListShares)
r.Post("/api/v1/task-lists/{id}/shares", s.handleCreateTaskListShare)
r.Delete("/api/v1/task-lists/{id}/shares/{shareId}", s.handleDeleteTaskListShare)
// Workspace Documents & Folders
r.Get("/api/v1/folders", s.handleGetFolders)
r.Post("/api/v1/folders", s.handleCreateFolder)
r.Delete("/api/v1/folders", s.handleDeleteFolder)
// Workspace Tags & Pages
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
r.Get("/api/v1/documents", s.handleGetDocuments)
r.Post("/api/v1/documents", s.handleCreateDocument)
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
// Document Sharing & Collaboration
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
// Document Locking
r.Get("/api/v1/documents/{id}/lock", s.handleGetDocLock)
r.Post("/api/v1/documents/{id}/lock", s.handleLockDoc)
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
// Shared Spaces & Realtime Lists
r.Get("/api/v1/spaces", s.handleGetSpaces)
r.Post("/api/v1/spaces", s.handleCreateSpace)
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList)
r.Get("/api/v1/lists", s.handleGetAllLists)
r.Delete("/api/v1/lists/{id}", s.handleDeleteList)
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
// 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.Post("/api/v1/teams", s.handleCreateTeamDirect)
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)
// API Keys Management
r.Get("/api/v1/keys", s.handleGetAPIKeys)
r.Post("/api/v1/keys", s.handleCreateAPIKey)
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
// Staff Routes (Server-Admin & Team-Admin)
r.Group(func(r chi.Router) {
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)
})
})
// Embedded Frontend SPA Handler
if s.staticFS != nil {
fileServer := http.FileServer(http.FS(s.staticFS))
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(s.staticFS, path); err != nil {
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
http.NotFound(w, req)
return
}
req.URL.Path = "/"
path = "index.html"
}
if path == "index.html" || path == "sw.js" || path == "manifest.json" {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
fileServer.ServeHTTP(w, req)
})
}
return r
}
// --- HELPERS & MIDDLEWARE ---
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check Authorization Bearer Token
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if strings.HasPrefix(tokenStr, "mindsift_sec_") {
user, err := s.store.ValidateAPIKeyAndGetUser(tokenStr)
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 {
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
}
}
}
// 2. Check X-API-Key Header
apiKey := r.Header.Get("X-API-Key")
if apiKey != "" {
user, err := s.store.ValidateAPIKeyAndGetUser(apiKey)
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 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)
})
}
func (s *Server) adminMiddleware(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" {
writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich")
return
}
next.ServeHTTP(w, r)
})
}
// --- HANDLERS ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"service": "mindsift",
"time": domain.NowISO(),
})
}
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,
"demoMode": s.cfg.DemoMode,
})
}
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")
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 = 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
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
if err != nil {
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
return
}
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 {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Username string `json:"username"`
Login string `json:"login"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
identifier := strings.TrimSpace(req.Login)
if identifier == "" {
identifier = strings.TrimSpace(req.Username)
}
if identifier == "" {
identifier = strings.TrimSpace(req.Email)
}
if identifier == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort erforderlich")
return
}
user, err := s.store.GetUserByLogin(identifier)
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !user.IsActive {
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
return
}
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.StatusOK, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "Automatischer Gast-Login ist deaktiviert – bitte anmelden")
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
if err != nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
stats, _ := s.store.GetDashboardStats(userID)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user": user,
"stats": stats,
})
}
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
stats, err := s.store.GetDashboardStats(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, stats)
}
func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Content string `json:"content"`
Priority string `json:"priority"`
Source string `json:"source"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
Target string `json:"target"` // inbox | task | list_item | document
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_list_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
targetListID := req.TargetListID
if targetListID == "" {
targetListID = req.TargetList2
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, req.Source, dueDate, taskListID, "todo", "")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
target := strings.TrimSpace(req.Target)
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
thought = converted
if target == "list_item" && targetListID != "" {
if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
}
}
}
}
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
status := r.URL.Query().Get("status")
thoughts, err := s.store.GetThoughts(userID, status, 250)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Action string `json:"action"` // keep | discard | restore
ConvertTo string `json:"convertTo"`
ConvertTo2 string `json:"convert_to"`
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_list_id"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
convertTo := req.ConvertTo
if convertTo == "" {
convertTo = req.ConvertTo2
}
targetListID := req.TargetListID
if targetListID == "" {
targetListID = req.TargetList2
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// If converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
}
}
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := r.URL.Query().Get("taskListId")
tasks, err := s.store.GetTasks(userID, taskListID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Content *string `json:"content"`
IsCompleted *bool `json:"isCompleted"`
DueDate *string `json:"dueDate"`
Priority *string `json:"priority"`
TaskListID *string `json:"taskListId"`
KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
count, err := s.store.PurgeDiscardedThoughts(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"purged": count})
}
// --- DOCUMENTS ---
func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docs, err := s.store.GetDocuments(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, docs)
}
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SpaceID string `json:"spaceId"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
Blocks []domain.DocumentBlock `json:"blocks"`
Tags []string `json:"tags"`
IsPinned bool `json:"isPinned"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc)
writeJSON(w, http.StatusCreated, doc)
}
func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
// Check document lock
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
"currentLock": lock,
})
return
}
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
FolderPath *string `json:"folderPath"`
Blocks *[]domain.DocumentBlock `json:"blocks"`
Tags *[]string `json:"tags"`
IsPinned *bool `json:"isPinned"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
cleanPath, err := s.store.CreateFolder(userID, req.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath})
writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath})
}
func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderPath := r.URL.Query().Get("path")
if folderPath == "" {
writeError(w, http.StatusBadRequest, "Pfad erforderlich")
return
}
deletedDocIDs, err := s.store.DeleteFolder(userID, folderPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, docID := range deletedDocIDs {
s.wsHub.ReleaseDocLock(docID, userID, true)
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
writeJSON(w, http.StatusOK, map[string]interface{}{
"deleted": true,
"deletedDocIds": deletedDocIDs,
})
}
// --- WORKSPACE TAGS & PAGES ---
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tags, err := s.store.GetWorkspaceTags(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tags)
}
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tag := chi.URLParam(r, "tag")
details, err := s.store.GetTagDetails(userID, tag)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, details)
}
// --- DOCUMENT SHARING & COLLABORATION ---
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
shares, err := s.store.GetDocShares(docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
TeamID string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.TeamID != "" {
members, err := s.store.GetTeamMembers(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "Team nicht gefunden")
return
}
var lastShare *domain.DocShare
for _, tm := range members {
if tm.UserID == userID {
continue
}
share, err := s.store.CreateDocShare(userID, docID, tm.UserID, req.Permission)
if err == nil && share != nil {
lastShare = share
s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share)
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
}
}
if lastShare != nil {
writeJSON(w, http.StatusCreated, lastShare)
} else {
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
return
}
target := req.UserID
if target == "" {
target = req.Email
}
if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
return
}
share, err := s.store.CreateDocShare(userID, docID, target, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share)
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
targetUserID := chi.URLParam(r, "userId")
if err := s.store.DeleteDocShare(userID, docID, targetUserID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(targetUserID, "DOC_UNSHARED", map[string]string{"docId": docID})
s.wsHub.BroadcastDocEvent(docID, "DOC_UNSHARED", map[string]string{"docId": docID, "userId": targetUserID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
users, err := s.store.GetCollaborators(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
// --- DOCUMENT LOCKING ---
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
lock := s.wsHub.GetDocLock(docID)
if lock == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": true, "lock": lock})
}
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
user, err := s.store.GetUserByID(userID)
userName := "Ein Benutzer"
if err == nil && user != nil && user.DisplayName != "" {
userName = user.DisplayName
}
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second)
if !acquired {
w.WriteHeader(http.StatusLocked)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"locked": true,
"acquired": false,
"currentLock": currentLock,
"message": "Wird aktuell bearbeitet von " + currentLock.UserName,
})
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
writeJSON(w, http.StatusOK, map[string]interface{}{
"locked": true,
"acquired": true,
"lock": info,
})
}
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
ok := s.wsHub.HeartbeatDocLock(docID, userID, 12*time.Second)
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
}
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
user, _ := s.store.GetUserByID(userID)
isAdmin := user != nil && user.Role == "admin"
released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin)
if released {
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
}
// --- SPACES & REALTIME LISTS ---
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
spaces, err := s.store.GetSpacesForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, spaces)
}
func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, sp)
}
func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
InviteCode string `json:"inviteCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID})
s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID})
writeJSON(w, http.StatusOK, sp)
}
func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
spaceID := chi.URLParam(r, "id")
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
TeamID string `json:"teamId"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role != "owner" && req.Role != "editor" && req.Role != "viewer" {
req.Role = "editor"
}
if req.TeamID != "" {
members, err := s.store.GetTeamMembers(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "Team nicht gefunden")
return
}
var lastMember *domain.SpaceMember
for _, tm := range members {
member, err := s.store.AddSpaceMember(currentUserID, spaceID, tm.UserID, req.Role)
if err == nil && member != nil {
lastMember = member
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
}
}
if lastMember != nil {
writeJSON(w, http.StatusCreated, lastMember)
} else {
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
return
}
target := req.UserID
if target == "" {
target = req.Email
}
if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername, E-Mail oder Team erforderlich")
return
}
member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
writeJSON(w, http.StatusCreated, member)
}
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
spaceID := chi.URLParam(r, "id")
targetUserID := chi.URLParam(r, "userId")
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id")
lists, err := s.store.GetListsForSpace(spaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
lists, err := s.store.GetAllListsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id")
var req struct {
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst)
writeJSON(w, http.StatusCreated, lst)
}
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
lst, _ := s.store.GetListByID(listID)
if err := s.store.DeleteList(listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
var req struct {
Content string `json:"content"`
Category string `json:"category"`
Quantity string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item)
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
var req struct {
IsCompleted bool `json:"isCompleted"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
user, _ := s.store.GetUserByID(userID)
userName := "Familie"
if user != nil {
userName = user.DisplayName
}
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item)
}
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
_, err := s.store.DeleteListItem(itemID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{
"itemId": itemID,
"listId": listID,
})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
removed, err := s.store.ClearCompletedListItems(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst)
}
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "spaceId")
token := r.URL.Query().Get("token")
if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
userID := "anonymous"
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
userID = claims.UserID
}
}
s.wsHub.ServeWS(w, r, spaceID, userID)
}
// --- API KEYS & WEBHOOK INGESTION ---
func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keys, err := s.store.GetAPIKeys(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, keys)
}
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
key, err := s.store.CreateAPIKey(userID, req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, key)
}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keyID := chi.URLParam(r, "id")
if err := s.store.DeleteAPIKey(userID, keyID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
token = r.Header.Get("X-API-Key")
}
if token == "" && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
var user *domain.User
var err error
if strings.HasPrefix(token, "mindsift_sec_") {
user, err = s.store.ValidateAPIKeyAndGetUser(token)
} else if token != "" {
if claims, jwtErr := auth.ValidateToken(s.cfg.JWTSecret, token); jwtErr == nil {
user, err = s.store.GetUserByID(claims.UserID)
}
}
if err != nil || user == nil {
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Priority string `json:"priority"`
Tags []string `json:"tags"`
DueDate string `json:"dueDate"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON-Payload")
return
}
bodyText := strings.TrimSpace(req.Title)
if req.Content != "" {
if bodyText != "" {
bodyText = bodyText + " — " + strings.TrimSpace(req.Content)
} else {
bodyText = strings.TrimSpace(req.Content)
}
}
if bodyText == "" {
writeError(w, http.StatusBadRequest, "Feld 'title' oder 'content' ist erforderlich")
return
}
for _, tag := range req.Tags {
cleanTag := strings.TrimPrefix(strings.TrimSpace(tag), "#")
if cleanTag != "" && !strings.Contains(strings.ToLower(bodyText), "#"+strings.ToLower(cleanTag)) {
bodyText += fmt.Sprintf(" #%s", cleanTag)
}
}
if req.Priority == "" {
req.Priority = "high"
}
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, "webhook", req.DueDate)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"status": "ingested",
"thought": thought,
})
}
// --- SEARCH & EXPORT ---
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
q := r.URL.Query().Get("q")
thoughts, docs, err := s.store.SearchAll(userID, q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"thoughts": thoughts,
"documents": docs,
})
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
exportData, err := s.store.ExportUserData(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
writeJSON(w, http.StatusOK, exportData)
}
// --- 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"`
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")
return
}
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
hash, err := auth.HashPassword(*req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
return
}
_ = s.store.UpdateUserPassword(userID, hash)
}
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
}
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) {
userID := auth.GetUserID(r.Context())
lists, err := s.store.GetTaskLists(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SpaceID string `json:"spaceId"`
TeamID string `json:"teamId"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, tl)
}
func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
Color *string `json:"color"`
TeamID *string `json:"teamId"`
SpaceID *string `json:"spaceId"`
Columns *[]domain.KanbanColumn `json:"columns"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
writeJSON(w, http.StatusOK, tl)
}
func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if err := s.store.DeleteTaskList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToAll("TASK_LIST_DELETED", map[string]string{"id": listID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetTaskListShares(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if _, err := s.store.GetTaskListByID(userID, listID); err != nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden oder kein Zugriff")
return
}
shares, err := s.store.GetTaskListShares(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
tl, err := s.store.GetTaskListByID(userID, listID)
if err != nil || tl == nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
return
}
if tl.UserID != userID {
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann die Liste freigeben")
return
}
var req struct {
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
updatedList, _ := s.store.GetTaskListByID(userID, listID)
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
tl, err := s.store.GetTaskListByID(userID, listID)
if err != nil || tl == nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
return
}
if tl.UserID != userID {
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen")
return
}
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
updatedList, _ := s.store.GetTaskListByID(userID, listID)
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- ORGANIZATIONS & TEAMS ---
func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
orgs, err := s.store.GetOrganizationsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, orgs)
}
func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
org, err := s.store.CreateOrganization(userID, req.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
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)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, teams)
}
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"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
team, err := s.store.CreateTeam(orgID, req.Name, req.Description)
if err != nil {
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) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Description string `json:"description"`
OrgID string `json:"orgId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
return
}
orgID := strings.TrimSpace(req.OrgID)
if orgID == "" {
orgID = "default"
}
team, err := s.store.CreateTeam(orgID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team as team_admin
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, _ := s.store.GetUserByID(userID)
if (r.URL.Query().Get("all") == "true") || (user != nil && (user.Role == "admin" || user.Role == "team_admin")) {
teams, err := s.store.GetAllTeams()
if err == nil {
writeJSON(w, http.StatusOK, teams)
return
}
}
teams, err := s.store.GetTeamsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, teams)
}
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
}
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
}
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
}
// --- ADMIN & STAFF PANEL ---
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.GetAllUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
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"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Email == "" || req.Password == "" {
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")
return
}
u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, u)
}
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"`
Role *string `json:"role"`
IsActive *bool `json:"isActive"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
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())
return
}
writeJSON(w, http.StatusOK, u)
}
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"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Password) == "" {
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
return
}
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
targetUserID := chi.URLParam(r, "id")
currUserID := auth.GetUserID(r.Context())
if targetUserID == currUserID {
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
}
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)
}
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings domain.SystemSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, _ := s.store.GetUserByID(userID)
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()
for range ticker.C {
alarms, err := s.store.GetDueTasksForAlarm()
if err != nil || len(alarms) == 0 {
continue
}
today := time.Now().Format("2006-01-02")
for _, a := range alarms {
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)
}
}
}
}