package api import ( "bytes" "encoding/json" "fmt" "io/fs" "net/http" "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.startDiscordAlarmTicker() 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 & Webhook Routes r.Get("/api/v1/health", s.handleHealth) 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/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.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 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) // Workspace Documents 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) // 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.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.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/{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) // Admin Panel Routes r.Group(func(r chi.Router) { r.Use(s.adminMiddleware) 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/settings", s.handleAdminGetSettings) r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings) }) }) // 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 { // Never return index.html for missing JS/CSS assets if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") { http.NotFound(w, req) return } // SPA client-side routing fallback to index.html req.URL.Path = "/" path = "index.html" } 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 { next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) return } } claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr) if err == nil && claims != nil { userID := claims.UserID if _, userErr := s.store.GetUserByID(userID); userErr != nil { if defUser, defErr := s.store.EnsureDefaultUser(); defErr == nil && defUser != nil { userID = defUser.ID } } next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), userID))) 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 { next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) return } } writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte einloggen") }) } 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, "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) handleRegister(w http.ResponseWriter, r *http.Request) { sysSettings, _ := s.store.GetSystemSettings() if !s.cfg.AllowRegistration || (sysSettings != nil && !sysSettings.AllowRegistration) { count, _ := s.store.CountUsers() if count > 0 { writeError(w, http.StatusForbidden, "Registrierung ist auf dieser Instanz deaktiviert") return } } var req struct { Email string `json:"email"` Password string `json:"password"` DisplayName string `json:"displayName"` } 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] } hash, err := auth.HashPassword(req.Password) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing") return } count, _ := s.store.CountUsers() role := "member" if count == 0 { role = "admin" } user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role) if err != nil { writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits") return } _ = s.store.SeedStarterContent(user) 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"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } user, err := s.store.GetUserByEmail(req.Email) if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) { writeError(w, http.StatusUnauthorized, "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) { user, err := s.store.EnsureDefaultUser() if err != nil { writeError(w, http.StatusInternalServerError, "Standard-Profil konnte nicht geladen werden") return } token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) if err != nil { writeError(w, http.StatusInternalServerError, "Token-Fehler") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "token": token, "user": user, }) } 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) } } } } 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) } } 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"` } 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) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } 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 } 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 } 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") 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 } 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 } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } // --- 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 } writeJSON(w, http.StatusOK, sp) } 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") 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 --- func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { DisplayName *string `json:"displayName"` Email *string `json:"email"` Password *string `json:"password"` DiscordWebhookURL *string `json:"discordWebhookUrl"` NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"` } 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.UpdateUser(userID, req.DisplayName, req.Email, nil, nil, req.DiscordWebhookURL, req.NotifyBeforeMinutes) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, u) } // --- 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"` 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.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"` SpaceID *string `json:"spaceId"` } 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.SpaceID) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } 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 } 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) 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) { 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 } writeJSON(w, http.StatusCreated, team) } 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) { teamID := chi.URLParam(r, "id") var req struct { UserID string `json:"userId"` Role string `json:"role"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } if err := s.store.AddTeamMember(teamID, req.UserID, req.Role); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"added": true}) } 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 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) { 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 } 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) { targetUserID := chi.URLParam(r, "id") 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 } 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) { targetUserID := chi.URLParam(r, "id") 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 } if err := s.store.DeleteUser(targetUserID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } 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 } 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 } writeJSON(w, http.StatusOK, settings) } // --- DISCORD ALARMS BACKGROUND SCHEDULER --- func (s *Server) startDiscordAlarmTicker() { ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() httpClient := &http.Client{Timeout: 10 * time.Second} for range ticker.C { alarms, err := s.store.GetDueTasksForAlarm() if err != nil || len(alarms) == 0 { continue } today := time.Now().Format("2006-01-02") for _, a := range alarms { if a.DueDate <= today && a.DiscordWebhookURL != "" { payload := map[string]interface{}{ "content": fmt.Sprintf("⏰ **MindSift Aufgaben-Erinnerung**"), "embeds": []map[string]interface{}{ { "title": a.Title, "description": fmt.Sprintf("Diese Aufgabe ist fällig am **%s** (Priorität: %s)", a.DueDate, a.Priority), "color": 3066993, "footer": map[string]string{ "text": "MindSift Reminder", }, }, }, } body, mErr := json.Marshal(payload) if mErr == nil { resp, pErr := httpClient.Post(a.DiscordWebhookURL, "application/json", bytes.NewReader(body)) if pErr == nil { _ = resp.Body.Close() _ = s.store.MarkTaskAlarmSent(a.TaskID) } } } } } }