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) r.Get("/api/v1/ws/lists/{listId}", 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) r.Post("/api/v1/tasks/reorder", s.handleReorderTasks) r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks) // 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) r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders) r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder) r.Get("/api/v1/workspace/folders/shared/{id}", s.handleGetSharedFolderByID) r.Delete("/api/v1/workspace/folders/shared/{id}", s.handleDeleteSharedFolder) r.Put("/api/v1/workspace/folders/shared/{id}/mount", s.handleUpdateFolderMount) r.Get("/api/v1/workspace/folders/shared/{id}/shares", s.handleGetFolderShares) r.Post("/api/v1/workspace/folders/shared/{id}/shares", s.handleCreateFolderShare) r.Delete("/api/v1/workspace/folders/shared/{id}/shares/{shareId}", s.handleDeleteFolderShare) // Workspace Tags & Pages r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags) r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails) r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote) 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.Put("/api/v1/documents/{id}/move", s.handleMoveDocument) 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 Lists & Realtime Collaboration r.Get("/api/v1/lists", s.handleGetAllLists) r.Post("/api/v1/lists", s.handleCreateList) r.Get("/api/v1/lists/{id}", s.handleGetListByID) r.Delete("/api/v1/lists/{id}", s.handleDeleteList) r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares) r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare) r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare) 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}/items/reorder", s.handleReorderListItems) r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) 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.Put("/api/v1/teams/{id}", s.handleUpdateTeam) r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam) r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember) r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole) 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" { writeError(w, http.StatusForbidden, "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(userID, targetListID); lErr == nil && lst != nil { s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", 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(userID, targetListID); err == nil && lst != nil { s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", 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) handleReorderTasks(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) taskListID := chi.URLParam(r, "id") if taskListID == "" { taskListID = r.URL.Query().Get("taskListId") } var req struct { Items []domain.ThoughtReorderItem `json:"items"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } if err := s.store.ReorderTasks(taskListID, req.Items); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{ "taskListId": taskListID, "items": req.Items, "userId": userID, }) writeJSON(w, http.StatusOK, map[string]bool{"success": true}) } 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 SHARED FOLDERS --- func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) folders, err := s.store.GetSharedFolders(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, folders) } func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { Name string `json:"name"` MountPath string `json:"mountPath"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } folder, err := s.store.CreateSharedFolder(userID, req.Name, req.MountPath) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", folder) writeJSON(w, http.StatusCreated, folder) } func (s *Server) handleGetSharedFolderByID(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) folderID := chi.URLParam(r, "id") folder, err := s.store.GetSharedFolderByID(userID, folderID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, folder) } func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) folderID := chi.URLParam(r, "id") if err := s.store.DeleteSharedFolder(userID, folderID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) folderID := chi.URLParam(r, "id") var req struct { MountPath string `json:"mountPath"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } if err := s.store.UpdateFolderMount(userID, folderID, req.MountPath); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath}) writeJSON(w, http.StatusOK, map[string]bool{"updated": true}) } func (s *Server) handleGetFolderShares(w http.ResponseWriter, r *http.Request) { folderID := chi.URLParam(r, "id") shares, err := s.store.GetFolderShares(folderID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, shares) } func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) folderID := chi.URLParam(r, "id") var req struct { TargetType string `json:"targetType"` // team | user TargetID string `json:"targetId"` 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 } targetType := req.TargetType targetIdentifier := req.TargetID if targetIdentifier == "" { if req.TeamId != "" { targetType = "team" targetIdentifier = req.TeamId } else if req.UserId != "" { targetType = "user" targetIdentifier = req.UserId } else if req.Email != "" { targetType = "user" targetIdentifier = req.Email } } share, err := s.store.CreateFolderShare(userID, folderID, targetType, targetIdentifier, req.Permission) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share) writeJSON(w, http.StatusCreated, share) } func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) folderID := chi.URLParam(r, "id") shareID := chi.URLParam(r, "shareId") if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") var req struct { FolderPath string `json:"folderPath"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } doc, err := s.store.MoveDocument(userID, docID, req.FolderPath) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil) writeJSON(w, http.StatusOK, doc) } // --- 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) } func (s *Server) handleUpdateTagPageNote(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) tag := chi.URLParam(r, "tag") var req struct { Content string `json:"content"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } updatedAt, err := s.store.UpdateTagPageNote(userID, tag, req.Content) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{ "tag": tag, "content": req.Content, "updatedAt": updatedAt, }) } // --- 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 != "" { share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } members, _ := s.store.GetTeamMembers(req.TeamID) for _, tm := range members { s.wsHub.BroadcastToUser(tm.UserID, "DOC_SHARED", share) } s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share) writeJSON(w, http.StatusCreated, share) 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}) } // --- REALTIME LISTS & SHARING --- 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) handleGetListByID(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") lst, err := s.store.GetListByID(userID, listID) if err != nil { writeError(w, http.StatusNotFound, "Liste nicht gefunden") return } writeJSON(w, http.StatusOK, lst) } func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) 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(userID, req.Title, req.Icon, req.ListType) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst) writeJSON(w, http.StatusCreated, lst) } func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") if err := s.store.DeleteList(userID, listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") shares, err := s.store.GetListShares(listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, shares) } func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) { currentUserID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") var req struct { TargetType string `json:"targetType"` // "user" or "team" TargetID string `json:"targetId"` // userId/email or teamId Role string `json:"role"` // "editor" or "viewer" } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } targetType := strings.ToLower(strings.TrimSpace(req.TargetType)) if targetType != "team" { targetType = "user" } role := strings.ToLower(strings.TrimSpace(req.Role)) if role != "viewer" && role != "editor" { role = "editor" } share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if targetType == "team" { members, _ := s.store.GetTeamMembers(share.TargetID) for _, tm := range members { s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share) } } else { s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share) } s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share) writeJSON(w, http.StatusCreated, share) } func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) { currentUserID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") shareID := chi.URLParam(r, "shareId") if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{ "listId": listID, "shareId": shareID, }) writeJSON(w, http.StatusOK, map[string]bool{"success": 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 } s.wsHub.BroadcastListEvent(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 := "Mitglied" 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 } s.wsHub.BroadcastListEvent(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 } s.wsHub.BroadcastListEvent(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 } s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{ "listId": listID, "removed": removed, }) writeJSON(w, http.StatusOK, map[string]int64{"removed": removed}) } func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") var req struct { Items []domain.ListItemReorderItem `json:"items"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Invalid request body") return } if err := s.store.ReorderListItems(listID, req.Items); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{ "listId": listID, "items": req.Items, }) writeJSON(w, http.StatusOK, map[string]bool{"success": true}) } func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { roomID := chi.URLParam(r, "listId") if roomID == "" { roomID = chi.URLParam(r, "spaceId") } if roomID == "" { roomID = r.URL.Query().Get("listId") } if roomID == "" { roomID = r.URL.Query().Get("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, roomID, 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"` AvatarURL *string `json:"avatarUrl"` 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, req.AvatarURL, ) 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, "admin") team.Members, _ = s.store.GetTeamMembers(team.ID) writeJSON(w, http.StatusCreated, team) } func (s *Server) isTeamAdmin(userID, teamID string) bool { if userID == "" || teamID == "" { return false } role, err := s.store.GetTeamMemberRole(teamID, userID) if err != nil { return false } return role == "admin" } 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 group admin _ = s.store.AddTeamMember(team.ID, userID, "admin") team.Members, _ = s.store.GetTeamMembers(team.ID) writeJSON(w, http.StatusCreated, team) } func (s *Server) handleUpdateTeam(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) teamID := chi.URLParam(r, "id") if !s.isTeamAdmin(userID, teamID) { writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe bearbeiten") return } 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 } name := strings.TrimSpace(req.Name) if name == "" { writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich") return } updated, err := s.store.UpdateTeam(teamID, name, req.Description) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, updated) } func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) teamID := chi.URLParam(r, "id") if !s.isTeamAdmin(userID, teamID) { writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe löschen") return } 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) 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) { userID := auth.GetUserID(r.Context()) teamID := chi.URLParam(r, "id") if !s.isTeamAdmin(userID, teamID) { writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitglieder hinzufügen") return } 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 } role := strings.ToLower(strings.TrimSpace(req.Role)) if role != "admin" && role != "member" { role = "member" } if err := s.store.AddTeamMember(teamID, targetUserID, role); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } members, _ := s.store.GetTeamMembers(teamID) writeJSON(w, http.StatusOK, members) } func (s *Server) handleUpdateTeamMemberRole(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) teamID := chi.URLParam(r, "id") memberUserID := chi.URLParam(r, "userId") if !s.isTeamAdmin(userID, teamID) { writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitgliedsrollen ändern") return } var req struct { Role string `json:"role"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } role := strings.ToLower(strings.TrimSpace(req.Role)) if role != "admin" && role != "member" { writeError(w, http.StatusBadRequest, "Ungültige Rolle (erlaubt sind: admin, member)") return } if err := s.store.UpdateTeamMemberRole(teamID, memberUserID, 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) { userID := auth.GetUserID(r.Context()) teamID := chi.URLParam(r, "id") memberUserID := chi.URLParam(r, "userId") isSelf := userID == memberUserID isAdmin := s.isTeamAdmin(userID, teamID) if !isSelf && !isAdmin { writeError(w, http.StatusForbidden, "Nicht berechtigt, dieses Mitglied zu entfernen") return } if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil { writeError(w, http.StatusBadRequest, 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) } } } }