MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
2179 lines
67 KiB
Go
2179 lines
67 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io/fs"
|
||
"net/http"
|
||
"net/smtp"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/go-chi/chi/v5/middleware"
|
||
"github.com/go-chi/cors"
|
||
"github.com/sandrodach/mindsift/internal/auth"
|
||
"github.com/sandrodach/mindsift/internal/config"
|
||
"github.com/sandrodach/mindsift/internal/domain"
|
||
"github.com/sandrodach/mindsift/internal/storage"
|
||
"github.com/sandrodach/mindsift/internal/ws"
|
||
)
|
||
|
||
type Server struct {
|
||
cfg *config.Config
|
||
store *storage.Store
|
||
wsHub *ws.Hub
|
||
staticFS fs.FS
|
||
}
|
||
|
||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||
srv := &Server{
|
||
cfg: cfg,
|
||
store: store,
|
||
wsHub: wsHub,
|
||
staticFS: staticFS,
|
||
}
|
||
go srv.startAlarmTicker()
|
||
return srv
|
||
}
|
||
|
||
func (s *Server) Router() http.Handler {
|
||
r := chi.NewRouter()
|
||
|
||
r.Use(middleware.RealIP)
|
||
r.Use(middleware.Recoverer)
|
||
r.Use(cors.Handler(cors.Options{
|
||
AllowedOrigins: []string{"*"},
|
||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||
ExposedHeaders: []string{"Link"},
|
||
AllowCredentials: true,
|
||
MaxAge: 300,
|
||
}))
|
||
|
||
// Public Health, Setup & Auth Routes
|
||
r.Get("/api/v1/health", s.handleHealth)
|
||
r.Get("/api/v1/auth/status", s.handleAuthStatus)
|
||
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
|
||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
||
r.Get("/api/v1/ws", s.handleWebSocket)
|
||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||
|
||
// Authenticated API Routes
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.authMiddleware)
|
||
|
||
r.Get("/api/v1/auth/me", s.handleMe)
|
||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
|
||
r.Get("/api/v1/stats", s.handleStats)
|
||
r.Get("/api/v1/search", s.handleSearch)
|
||
r.Get("/api/v1/export", s.handleExport)
|
||
|
||
// Instant Capture & Thoughts
|
||
r.Post("/api/v1/inbox", s.handleCaptureInbox)
|
||
r.Get("/api/v1/thoughts", s.handleGetThoughts)
|
||
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
|
||
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
|
||
r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded)
|
||
|
||
// Tinder-Style Triage & Tasks
|
||
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
|
||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||
|
||
// Task Lists
|
||
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 & Folders
|
||
r.Get("/api/v1/folders", s.handleGetFolders)
|
||
r.Post("/api/v1/folders", s.handleCreateFolder)
|
||
r.Delete("/api/v1/folders", s.handleDeleteFolder)
|
||
|
||
// Workspace Tags & Pages
|
||
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||
|
||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
||
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
|
||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||
|
||
// Document Sharing & Collaboration
|
||
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
|
||
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
||
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
||
|
||
// Document Locking
|
||
r.Get("/api/v1/documents/{id}/lock", s.handleGetDocLock)
|
||
r.Post("/api/v1/documents/{id}/lock", s.handleLockDoc)
|
||
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
|
||
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
|
||
|
||
// Shared Spaces & Realtime Lists
|
||
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
||
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
|
||
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
||
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
||
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList)
|
||
|
||
r.Get("/api/v1/lists", s.handleGetAllLists)
|
||
r.Delete("/api/v1/lists/{id}", s.handleDeleteList)
|
||
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
||
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
|
||
|
||
// Organizations & Teams
|
||
r.Get("/api/v1/organizations", s.handleGetOrganizations)
|
||
r.Post("/api/v1/organizations", s.handleCreateOrganization)
|
||
r.Delete("/api/v1/organizations/{id}", s.handleDeleteOrganization)
|
||
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
|
||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
||
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
|
||
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
|
||
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||
|
||
// API Keys Management
|
||
r.Get("/api/v1/keys", s.handleGetAPIKeys)
|
||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
|
||
|
||
// Staff Routes (Server-Admin & Team-Admin)
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.staffMiddleware)
|
||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
|
||
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
|
||
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
|
||
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
|
||
|
||
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
|
||
})
|
||
|
||
// Server-Admin Strictly Protected Routes
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.adminMiddleware)
|
||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
|
||
})
|
||
})
|
||
|
||
// Embedded Frontend SPA Handler
|
||
if s.staticFS != nil {
|
||
fileServer := http.FileServer(http.FS(s.staticFS))
|
||
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
|
||
path := strings.TrimPrefix(req.URL.Path, "/")
|
||
if path == "" {
|
||
path = "index.html"
|
||
}
|
||
if _, err := fs.Stat(s.staticFS, path); err != nil {
|
||
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
|
||
http.NotFound(w, req)
|
||
return
|
||
}
|
||
req.URL.Path = "/"
|
||
path = "index.html"
|
||
}
|
||
if path == "index.html" || path == "sw.js" || path == "manifest.json" {
|
||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||
w.Header().Set("Pragma", "no-cache")
|
||
w.Header().Set("Expires", "0")
|
||
}
|
||
fileServer.ServeHTTP(w, req)
|
||
})
|
||
}
|
||
|
||
return r
|
||
}
|
||
|
||
// --- HELPERS & MIDDLEWARE ---
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(data)
|
||
}
|
||
|
||
func writeError(w http.ResponseWriter, status int, message string) {
|
||
writeJSON(w, status, map[string]string{"error": message})
|
||
}
|
||
|
||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// 1. Check Authorization Bearer Token
|
||
authHeader := r.Header.Get("Authorization")
|
||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||
if strings.HasPrefix(tokenStr, "mindsift_sec_") {
|
||
user, err := s.store.ValidateAPIKeyAndGetUser(tokenStr)
|
||
if err == nil && user != nil && user.IsActive {
|
||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||
return
|
||
}
|
||
}
|
||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
|
||
if err == nil && claims != nil {
|
||
user, userErr := s.store.GetUserByID(claims.UserID)
|
||
if userErr == nil && user != nil && user.IsActive {
|
||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Check X-API-Key Header
|
||
apiKey := r.Header.Get("X-API-Key")
|
||
if apiKey != "" {
|
||
user, err := s.store.ValidateAPIKeyAndGetUser(apiKey)
|
||
if err == nil && user != nil && user.IsActive {
|
||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||
return
|
||
}
|
||
}
|
||
|
||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||
})
|
||
}
|
||
|
||
func (s *Server) staffMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
if userID == "" {
|
||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||
return
|
||
}
|
||
user, err := s.store.GetUserByID(userID)
|
||
if err != nil || user == nil || (user.Role != "admin" && user.Role != "team_admin") {
|
||
writeError(w, http.StatusForbidden, "Admin- oder Team-Admin-Berechtigung erforderlich")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func (s *Server) adminMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
if userID == "" {
|
||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||
return
|
||
}
|
||
user, err := s.store.GetUserByID(userID)
|
||
if err != nil || user == nil || user.Role != "admin" {
|
||
writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// --- HANDLERS ---
|
||
|
||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"status": "ok",
|
||
"service": "mindsift",
|
||
"time": domain.NowISO(),
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||
count, _ := s.store.CountUsers()
|
||
sys, _ := s.store.GetSystemSettings()
|
||
allowReg := false
|
||
instanceName := "MindSift"
|
||
if sys != nil {
|
||
allowReg = sys.AllowRegistration
|
||
if sys.InstanceName != "" {
|
||
instanceName = sys.InstanceName
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"needsSetup": count == 0,
|
||
"allowRegistration": allowReg,
|
||
"instanceName": instanceName,
|
||
"demoMode": s.cfg.DemoMode,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||
count, err := s.store.CountUsers()
|
||
if err != nil || count > 0 {
|
||
writeError(w, http.StatusForbidden, "Ersteinrichtung wurde bereits abgeschlossen")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
DisplayName string `json:"displayName"`
|
||
InstanceName string `json:"instanceName"`
|
||
AllowRegistration bool `json:"allowRegistration"`
|
||
SharingMode string `json:"sharingMode"`
|
||
SeedSampleData bool `json:"seedSampleData"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.DisplayName) == "" {
|
||
req.DisplayName = "Administrator"
|
||
}
|
||
|
||
hash, err := auth.HashPassword(req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
|
||
return
|
||
}
|
||
|
||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, "admin")
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
sys, _ := s.store.GetSystemSettings()
|
||
if sys != nil {
|
||
if strings.TrimSpace(req.InstanceName) != "" {
|
||
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
||
}
|
||
sys.AllowRegistration = req.AllowRegistration
|
||
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
|
||
sys.SharingMode = req.SharingMode
|
||
}
|
||
_ = s.store.UpdateSystemSettings(sys)
|
||
}
|
||
|
||
if req.SeedSampleData {
|
||
_ = s.store.SeedStarterContent(user)
|
||
}
|
||
|
||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||
"token": token,
|
||
"user": user,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
DisplayName string `json:"displayName"`
|
||
InviteCode string `json:"inviteCode"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.DisplayName) == "" {
|
||
req.DisplayName = strings.Split(req.Email, "@")[0]
|
||
}
|
||
|
||
count, _ := s.store.CountUsers()
|
||
role := "member"
|
||
var validInvite *domain.UserInvite
|
||
|
||
if strings.TrimSpace(req.InviteCode) != "" {
|
||
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
|
||
if err != nil {
|
||
writeError(w, http.StatusForbidden, err.Error())
|
||
return
|
||
}
|
||
if inv.Email != "" && !strings.EqualFold(inv.Email, strings.TrimSpace(req.Email)) {
|
||
writeError(w, http.StatusForbidden, "Dieser Einladungscode ist an eine andere E-Mail-Adresse gebunden")
|
||
return
|
||
}
|
||
validInvite = inv
|
||
role = inv.Role
|
||
} else if count == 0 {
|
||
role = "admin"
|
||
} else {
|
||
sysSettings, _ := s.store.GetSystemSettings()
|
||
if sysSettings == nil || !sysSettings.AllowRegistration {
|
||
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
|
||
return
|
||
}
|
||
}
|
||
|
||
hash, err := auth.HashPassword(req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
|
||
return
|
||
}
|
||
|
||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
|
||
if err != nil {
|
||
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
|
||
return
|
||
}
|
||
|
||
if validInvite != nil {
|
||
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
|
||
if validInvite.TeamID != "" {
|
||
_ = s.store.AddTeamMember(validInvite.TeamID, user.ID, "member")
|
||
}
|
||
}
|
||
|
||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||
"token": token,
|
||
"user": user,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
Username string `json:"username"`
|
||
Login string `json:"login"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||
return
|
||
}
|
||
|
||
identifier := strings.TrimSpace(req.Login)
|
||
if identifier == "" {
|
||
identifier = strings.TrimSpace(req.Username)
|
||
}
|
||
if identifier == "" {
|
||
identifier = strings.TrimSpace(req.Email)
|
||
}
|
||
|
||
if identifier == "" || req.Password == "" {
|
||
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort erforderlich")
|
||
return
|
||
}
|
||
|
||
user, err := s.store.GetUserByLogin(identifier)
|
||
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||
return
|
||
}
|
||
if !user.IsActive {
|
||
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
|
||
return
|
||
}
|
||
|
||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"token": token,
|
||
"user": user,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
|
||
writeError(w, http.StatusForbidden, "Automatischer Gast-Login ist deaktiviert – bitte anmelden")
|
||
}
|
||
|
||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
user, err := s.store.GetUserByID(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||
return
|
||
}
|
||
stats, _ := s.store.GetDashboardStats(userID)
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"user": user,
|
||
"stats": stats,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
stats, err := s.store.GetDashboardStats(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, stats)
|
||
}
|
||
|
||
func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
Content string `json:"content"`
|
||
Priority string `json:"priority"`
|
||
Source string `json:"source"`
|
||
DueDate string `json:"dueDate"`
|
||
DueDate2 string `json:"due_date"`
|
||
Target string `json:"target"` // inbox | task | list_item | document
|
||
TargetListID string `json:"targetListId"`
|
||
TargetList2 string `json:"target_list_id"`
|
||
TaskListID string `json:"taskListId"`
|
||
TaskListID2 string `json:"task_list_id"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
dueDate := req.DueDate
|
||
if dueDate == "" {
|
||
dueDate = req.DueDate2
|
||
}
|
||
targetListID := req.TargetListID
|
||
if targetListID == "" {
|
||
targetListID = req.TargetList2
|
||
}
|
||
taskListID := req.TaskListID
|
||
if taskListID == "" {
|
||
taskListID = req.TaskListID2
|
||
}
|
||
|
||
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, req.Source, dueDate, taskListID, "todo", "")
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
target := strings.TrimSpace(req.Target)
|
||
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
|
||
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
|
||
thought = converted
|
||
if target == "list_item" && targetListID != "" {
|
||
if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil {
|
||
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought)
|
||
writeJSON(w, http.StatusCreated, thought)
|
||
}
|
||
|
||
func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
status := r.URL.Query().Get("status")
|
||
thoughts, err := s.store.GetThoughts(userID, status, 250)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, thoughts)
|
||
}
|
||
|
||
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, thoughts)
|
||
}
|
||
|
||
func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
thoughtID := chi.URLParam(r, "id")
|
||
|
||
var req struct {
|
||
Action string `json:"action"` // keep | discard | restore
|
||
ConvertTo string `json:"convertTo"`
|
||
ConvertTo2 string `json:"convert_to"`
|
||
TargetListID string `json:"targetListId"`
|
||
TargetList2 string `json:"target_list_id"`
|
||
TaskListID string `json:"taskListId"`
|
||
TaskListID2 string `json:"task_list_id"`
|
||
DueDate string `json:"dueDate"`
|
||
DueDate2 string `json:"due_date"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
convertTo := req.ConvertTo
|
||
if convertTo == "" {
|
||
convertTo = req.ConvertTo2
|
||
}
|
||
targetListID := req.TargetListID
|
||
if targetListID == "" {
|
||
targetListID = req.TargetList2
|
||
}
|
||
taskListID := req.TaskListID
|
||
if taskListID == "" {
|
||
taskListID = req.TaskListID2
|
||
}
|
||
dueDate := req.DueDate
|
||
if dueDate == "" {
|
||
dueDate = req.DueDate2
|
||
}
|
||
|
||
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
// If converted to a list item, broadcast via WebSocket to the space
|
||
if convertTo == "list_item" && targetListID != "" {
|
||
if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil {
|
||
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
|
||
}
|
||
}
|
||
|
||
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
||
writeJSON(w, http.StatusOK, updated)
|
||
}
|
||
|
||
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
taskListID := r.URL.Query().Get("taskListId")
|
||
tasks, err := s.store.GetTasks(userID, taskListID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, tasks)
|
||
}
|
||
|
||
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
thoughtID := chi.URLParam(r, "id")
|
||
|
||
var req struct {
|
||
Content *string `json:"content"`
|
||
IsCompleted *bool `json:"isCompleted"`
|
||
DueDate *string `json:"dueDate"`
|
||
Priority *string `json:"priority"`
|
||
TaskListID *string `json:"taskListId"`
|
||
KanbanStatus *string `json:"kanbanStatus"`
|
||
Notes *string `json:"notes"`
|
||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||
}
|
||
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)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastTaskEvent(updated.UserID, "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.BroadcastTaskEvent(userID, "TASK_DELETED", map[string]string{"id": thoughtID})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
count, err := s.store.PurgeDiscardedThoughts(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]int64{"purged": count})
|
||
}
|
||
|
||
// --- DOCUMENTS ---
|
||
|
||
func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docs, err := s.store.GetDocuments(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, docs)
|
||
}
|
||
|
||
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
SpaceID string `json:"spaceId"`
|
||
Title string `json:"title"`
|
||
Icon string `json:"icon"`
|
||
FolderPath string `json:"folderPath"`
|
||
Blocks []domain.DocumentBlock `json:"blocks"`
|
||
Tags []string `json:"tags"`
|
||
IsPinned bool `json:"isPinned"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc)
|
||
writeJSON(w, http.StatusCreated, doc)
|
||
}
|
||
|
||
func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, doc)
|
||
}
|
||
|
||
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
|
||
// Check document lock
|
||
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
|
||
w.WriteHeader(http.StatusLocked)
|
||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
|
||
"currentLock": lock,
|
||
})
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Title *string `json:"title"`
|
||
Icon *string `json:"icon"`
|
||
FolderPath *string `json:"folderPath"`
|
||
Blocks *[]domain.DocumentBlock `json:"blocks"`
|
||
Tags *[]string `json:"tags"`
|
||
IsPinned *bool `json:"isPinned"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
|
||
}
|
||
writeJSON(w, http.StatusOK, doc)
|
||
}
|
||
|
||
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
// --- WORKSPACE FOLDERS ---
|
||
|
||
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
folders, err := s.store.GetFolders(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, folders)
|
||
}
|
||
|
||
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
Path string `json:"path"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
cleanPath, err := s.store.CreateFolder(userID, req.Path)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath})
|
||
writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath})
|
||
}
|
||
|
||
func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
folderPath := r.URL.Query().Get("path")
|
||
if folderPath == "" {
|
||
writeError(w, http.StatusBadRequest, "Pfad erforderlich")
|
||
return
|
||
}
|
||
|
||
deletedDocIDs, err := s.store.DeleteFolder(userID, folderPath)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
for _, docID := range deletedDocIDs {
|
||
s.wsHub.ReleaseDocLock(docID, userID, true)
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
||
}
|
||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
|
||
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"deleted": true,
|
||
"deletedDocIds": deletedDocIDs,
|
||
})
|
||
}
|
||
|
||
// --- WORKSPACE TAGS & PAGES ---
|
||
|
||
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
tags, err := s.store.GetWorkspaceTags(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, tags)
|
||
}
|
||
|
||
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
tag := chi.URLParam(r, "tag")
|
||
details, err := s.store.GetTagDetails(userID, tag)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, details)
|
||
}
|
||
|
||
// --- DOCUMENT SHARING & COLLABORATION ---
|
||
|
||
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
||
docID := chi.URLParam(r, "id")
|
||
shares, err := s.store.GetDocShares(docID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, shares)
|
||
}
|
||
|
||
func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
UserID string `json:"userId"`
|
||
Email string `json:"email"`
|
||
Permission string `json:"permission"` // read | write
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
target := req.UserID
|
||
if target == "" {
|
||
target = req.Email
|
||
}
|
||
if target == "" {
|
||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||
return
|
||
}
|
||
|
||
share, err := s.store.CreateDocShare(userID, docID, target, req.Permission)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share)
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
|
||
writeJSON(w, http.StatusCreated, share)
|
||
}
|
||
|
||
func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
targetUserID := chi.URLParam(r, "userId")
|
||
if err := s.store.DeleteDocShare(userID, docID, targetUserID); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastToUser(targetUserID, "DOC_UNSHARED", map[string]string{"docId": docID})
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_UNSHARED", map[string]string{"docId": docID, "userId": targetUserID})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
users, err := s.store.GetCollaborators(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, users)
|
||
}
|
||
|
||
// --- DOCUMENT LOCKING ---
|
||
|
||
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
|
||
docID := chi.URLParam(r, "id")
|
||
lock := s.wsHub.GetDocLock(docID)
|
||
if lock == nil {
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": true, "lock": lock})
|
||
}
|
||
|
||
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
|
||
user, err := s.store.GetUserByID(userID)
|
||
userName := "Ein Benutzer"
|
||
if err == nil && user != nil && user.DisplayName != "" {
|
||
userName = user.DisplayName
|
||
}
|
||
|
||
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second)
|
||
if !acquired {
|
||
w.WriteHeader(http.StatusLocked)
|
||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"locked": true,
|
||
"acquired": false,
|
||
"currentLock": currentLock,
|
||
"message": "Wird aktuell bearbeitet von " + currentLock.UserName,
|
||
})
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"locked": true,
|
||
"acquired": true,
|
||
"lock": info,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
ok := s.wsHub.HeartbeatDocLock(docID, userID, 12*time.Second)
|
||
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
|
||
}
|
||
|
||
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
user, _ := s.store.GetUserByID(userID)
|
||
isAdmin := user != nil && user.Role == "admin"
|
||
released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin)
|
||
if released {
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
|
||
}
|
||
|
||
// --- SPACES & REALTIME LISTS ---
|
||
|
||
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
spaces, err := s.store.GetSpacesForUser(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, spaces)
|
||
}
|
||
|
||
func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon"`
|
||
Description string `json:"description"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, sp)
|
||
}
|
||
|
||
func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
InviteCode string `json:"inviteCode"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID})
|
||
s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID})
|
||
|
||
writeJSON(w, http.StatusOK, sp)
|
||
}
|
||
|
||
func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||
currentUserID := auth.GetUserID(r.Context())
|
||
spaceID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
UserID string `json:"userId"`
|
||
Email string `json:"email"`
|
||
Role string `json:"role"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
target := req.UserID
|
||
if target == "" {
|
||
target = req.Email
|
||
}
|
||
if target == "" {
|
||
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername oder E-Mail erforderlich")
|
||
return
|
||
}
|
||
|
||
member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
||
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
|
||
|
||
writeJSON(w, http.StatusCreated, member)
|
||
}
|
||
|
||
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||
currentUserID := auth.GetUserID(r.Context())
|
||
spaceID := chi.URLParam(r, "id")
|
||
targetUserID := chi.URLParam(r, "userId")
|
||
|
||
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID})
|
||
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
|
||
spaceID := chi.URLParam(r, "id")
|
||
lists, err := s.store.GetListsForSpace(spaceID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, lists)
|
||
}
|
||
|
||
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
lists, err := s.store.GetAllListsForUser(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, lists)
|
||
}
|
||
|
||
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
||
spaceID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
Title string `json:"title"`
|
||
Icon string `json:"icon"`
|
||
ListType string `json:"listType"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst)
|
||
writeJSON(w, http.StatusCreated, lst)
|
||
}
|
||
|
||
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
lst, _ := s.store.GetListByID(listID)
|
||
if err := s.store.DeleteList(listID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if lst != nil {
|
||
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID})
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
Content string `json:"content"`
|
||
Category string `json:"category"`
|
||
Quantity string `json:"quantity"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item)
|
||
}
|
||
writeJSON(w, http.StatusCreated, item)
|
||
}
|
||
|
||
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
itemID := chi.URLParam(r, "itemId")
|
||
|
||
var req struct {
|
||
IsCompleted bool `json:"isCompleted"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
user, _ := s.store.GetUserByID(userID)
|
||
userName := "Familie"
|
||
if user != nil {
|
||
userName = user.DisplayName
|
||
}
|
||
|
||
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item)
|
||
}
|
||
writeJSON(w, http.StatusOK, item)
|
||
}
|
||
|
||
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
itemID := chi.URLParam(r, "itemId")
|
||
|
||
_, err := s.store.DeleteListItem(itemID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{
|
||
"itemId": itemID,
|
||
"listId": listID,
|
||
})
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
removed, err := s.store.ClearCompletedListItems(listID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
|
||
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst)
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
|
||
}
|
||
|
||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||
spaceID := chi.URLParam(r, "spaceId")
|
||
token := r.URL.Query().Get("token")
|
||
if token == "" {
|
||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||
}
|
||
}
|
||
userID := "anonymous"
|
||
if token != "" {
|
||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||
userID = claims.UserID
|
||
}
|
||
}
|
||
s.wsHub.ServeWS(w, r, spaceID, userID)
|
||
}
|
||
|
||
// --- API KEYS & WEBHOOK INGESTION ---
|
||
|
||
func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
keys, err := s.store.GetAPIKeys(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, keys)
|
||
}
|
||
|
||
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||
|
||
key, err := s.store.CreateAPIKey(userID, req.Name)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, key)
|
||
}
|
||
|
||
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
keyID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteAPIKey(userID, keyID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
|
||
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||
token := r.URL.Query().Get("token")
|
||
if token == "" {
|
||
token = r.Header.Get("X-API-Key")
|
||
}
|
||
if token == "" && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||
}
|
||
|
||
var user *domain.User
|
||
var err error
|
||
|
||
if strings.HasPrefix(token, "mindsift_sec_") {
|
||
user, err = s.store.ValidateAPIKeyAndGetUser(token)
|
||
} else if token != "" {
|
||
if claims, jwtErr := auth.ValidateToken(s.cfg.JWTSecret, token); jwtErr == nil {
|
||
user, err = s.store.GetUserByID(claims.UserID)
|
||
}
|
||
}
|
||
|
||
if err != nil || user == nil {
|
||
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Title string `json:"title"`
|
||
Content string `json:"content"`
|
||
Priority string `json:"priority"`
|
||
Tags []string `json:"tags"`
|
||
DueDate string `json:"dueDate"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON-Payload")
|
||
return
|
||
}
|
||
|
||
bodyText := strings.TrimSpace(req.Title)
|
||
if req.Content != "" {
|
||
if bodyText != "" {
|
||
bodyText = bodyText + " — " + strings.TrimSpace(req.Content)
|
||
} else {
|
||
bodyText = strings.TrimSpace(req.Content)
|
||
}
|
||
}
|
||
if bodyText == "" {
|
||
writeError(w, http.StatusBadRequest, "Feld 'title' oder 'content' ist erforderlich")
|
||
return
|
||
}
|
||
|
||
for _, tag := range req.Tags {
|
||
cleanTag := strings.TrimPrefix(strings.TrimSpace(tag), "#")
|
||
if cleanTag != "" && !strings.Contains(strings.ToLower(bodyText), "#"+strings.ToLower(cleanTag)) {
|
||
bodyText += fmt.Sprintf(" #%s", cleanTag)
|
||
}
|
||
}
|
||
|
||
if req.Priority == "" {
|
||
req.Priority = "high"
|
||
}
|
||
|
||
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, "webhook", req.DueDate)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||
"status": "ingested",
|
||
"thought": thought,
|
||
})
|
||
}
|
||
|
||
// --- SEARCH & EXPORT ---
|
||
|
||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
q := r.URL.Query().Get("q")
|
||
thoughts, docs, err := s.store.SearchAll(userID, q)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"thoughts": thoughts,
|
||
"documents": docs,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
exportData, err := s.store.ExportUserData(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
|
||
writeJSON(w, http.StatusOK, exportData)
|
||
}
|
||
|
||
// --- USER SETTINGS & ALARMS ---
|
||
|
||
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
DisplayName *string `json:"displayName"`
|
||
Email *string `json:"email"`
|
||
Password *string `json:"password"`
|
||
AlarmChannels *[]string `json:"alarmChannels"`
|
||
AlarmEmail *string `json:"alarmEmail"`
|
||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||
CustomWebhookURL *string `json:"customWebhookUrl"`
|
||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||
hash, err := auth.HashPassword(*req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
|
||
return
|
||
}
|
||
_ = s.store.UpdateUserPassword(userID, hash)
|
||
}
|
||
u, err := s.store.UpdateUserFull(
|
||
userID,
|
||
req.DisplayName,
|
||
req.Email,
|
||
nil,
|
||
nil,
|
||
req.AlarmChannels,
|
||
req.AlarmEmail,
|
||
req.DiscordWebhookURL,
|
||
req.CustomWebhookURL,
|
||
req.NotifyBeforeMinutes,
|
||
)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, u)
|
||
}
|
||
|
||
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
user, err := s.store.GetUserByID(userID)
|
||
if err != nil || user == nil {
|
||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Channel string `json:"channel"` // email | discord | webhook
|
||
Target string `json:"target"` // optional override URL or email
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
channel := strings.ToLower(strings.TrimSpace(req.Channel))
|
||
target := strings.TrimSpace(req.Target)
|
||
|
||
switch channel {
|
||
case "discord":
|
||
url := target
|
||
if url == "" {
|
||
url = user.DiscordWebhookURL
|
||
}
|
||
if url == "" {
|
||
writeError(w, http.StatusBadRequest, "Bitte eine Discord Webhook URL angeben")
|
||
return
|
||
}
|
||
if err := s.sendDiscordNotification(url, "Test-Alarm von MindSift", "Dein Discord-Alarmkanal funktioniert einwandfrei!", domain.NowISO()[:10], "normal"); err != nil {
|
||
writeError(w, http.StatusBadGateway, "Discord-Fehler: "+err.Error())
|
||
return
|
||
}
|
||
case "webhook":
|
||
url := target
|
||
if url == "" {
|
||
url = user.CustomWebhookURL
|
||
}
|
||
if url == "" {
|
||
writeError(w, http.StatusBadRequest, "Bitte eine Webhook-URL angeben")
|
||
return
|
||
}
|
||
if err := s.sendCustomWebhookNotification(url, "test_alarm", "Test-Alarm von MindSift", "Dein eigener Webhook empfängt MindSift-Alarme!", domain.NowISO()[:10], "normal"); err != nil {
|
||
writeError(w, http.StatusBadGateway, "Webhook-Fehler: "+err.Error())
|
||
return
|
||
}
|
||
case "email":
|
||
toEmail := target
|
||
if toEmail == "" {
|
||
toEmail = user.AlarmEmail
|
||
}
|
||
if toEmail == "" {
|
||
toEmail = user.Email
|
||
}
|
||
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
|
||
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
|
||
return
|
||
}
|
||
default:
|
||
writeError(w, http.StatusBadRequest, "Unbekannter Alarm-Kanal (erlaubt: email, discord, webhook)")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"sent": true,
|
||
"channel": channel,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
users, err := s.store.GetDiscoverableUsers(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, users)
|
||
}
|
||
|
||
// --- TASK LISTS ---
|
||
|
||
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
lists, err := s.store.GetTaskLists(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, lists)
|
||
}
|
||
|
||
func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
SpaceID string `json:"spaceId"`
|
||
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) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
||
orgID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteOrganization(orgID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
|
||
orgID := chi.URLParam(r, "id")
|
||
teams, err := s.store.GetTeamsForOrg(orgID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, teams)
|
||
}
|
||
|
||
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
orgID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
team, err := s.store.CreateTeam(orgID, req.Name, req.Description)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
// Automatically add creator to team
|
||
_ = s.store.AddTeamMember(team.ID, userID, "team_admin")
|
||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||
writeJSON(w, http.StatusCreated, team)
|
||
}
|
||
|
||
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
|
||
teamID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteTeam(teamID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) 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"`
|
||
Email string `json:"email"`
|
||
Role string `json:"role"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
targetUserID := strings.TrimSpace(req.UserID)
|
||
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
|
||
u, err := s.store.GetUserByEmail(req.Email)
|
||
if err != nil || u == nil {
|
||
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
|
||
return
|
||
}
|
||
targetUserID = u.ID
|
||
}
|
||
if targetUserID == "" {
|
||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||
return
|
||
}
|
||
if err := s.store.AddTeamMember(teamID, targetUserID, req.Role); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
members, _ := s.store.GetTeamMembers(teamID)
|
||
writeJSON(w, http.StatusOK, members)
|
||
}
|
||
|
||
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
|
||
teamID := chi.URLParam(r, "id")
|
||
memberUserID := chi.URLParam(r, "userId")
|
||
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
||
}
|
||
|
||
// --- ADMIN & STAFF PANEL ---
|
||
|
||
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||
users, err := s.store.GetAllUsers()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, users)
|
||
}
|
||
|
||
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||
callerID := auth.GetUserID(r.Context())
|
||
caller, _ := s.store.GetUserByID(callerID)
|
||
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
DisplayName string `json:"displayName"`
|
||
Role string `json:"role"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Email == "" || req.Password == "" {
|
||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
|
||
return
|
||
}
|
||
// Only full server admin can create another server admin
|
||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||
req.Role = "member"
|
||
}
|
||
if strings.TrimSpace(req.DisplayName) == "" {
|
||
req.DisplayName = strings.Split(req.Email, "@")[0]
|
||
}
|
||
|
||
hash, err := auth.HashPassword(req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||
return
|
||
}
|
||
u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, u)
|
||
}
|
||
|
||
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||
callerID := auth.GetUserID(r.Context())
|
||
caller, _ := s.store.GetUserByID(callerID)
|
||
targetUserID := chi.URLParam(r, "id")
|
||
targetUser, err := s.store.GetUserByID(targetUserID)
|
||
if err != nil || targetUser == nil {
|
||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||
return
|
||
}
|
||
|
||
// Team-Admins cannot modify Server-Admins
|
||
if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" {
|
||
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
DisplayName *string `json:"displayName"`
|
||
Email *string `json:"email"`
|
||
Role *string `json:"role"`
|
||
IsActive *bool `json:"isActive"`
|
||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben")
|
||
return
|
||
}
|
||
|
||
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, u)
|
||
}
|
||
|
||
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||
callerID := auth.GetUserID(r.Context())
|
||
caller, _ := s.store.GetUserByID(callerID)
|
||
targetUserID := chi.URLParam(r, "id")
|
||
targetUser, _ := s.store.GetUserByID(targetUserID)
|
||
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
|
||
writeError(w, http.StatusForbidden, "Keine Berechtigung")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Password) == "" {
|
||
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
|
||
return
|
||
}
|
||
hash, err := auth.HashPassword(req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||
return
|
||
}
|
||
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||
targetUserID := chi.URLParam(r, "id")
|
||
currUserID := auth.GetUserID(r.Context())
|
||
if targetUserID == currUserID {
|
||
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
|
||
return
|
||
}
|
||
caller, _ := s.store.GetUserByID(currUserID)
|
||
targetUser, _ := s.store.GetUserByID(targetUserID)
|
||
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
|
||
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins")
|
||
return
|
||
}
|
||
if err := s.store.DeleteUser(targetUserID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
// --- INVITES MANAGEMENT ---
|
||
|
||
func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) {
|
||
invites, err := s.store.GetUserInvites()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, invites)
|
||
}
|
||
|
||
func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||
callerID := auth.GetUserID(r.Context())
|
||
caller, _ := s.store.GetUserByID(callerID)
|
||
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
Role string `json:"role"`
|
||
TeamID string `json:"teamId"`
|
||
ValidDays int `json:"validDays"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||
req.Role = "member"
|
||
}
|
||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.TeamID, req.ValidDays)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, inv)
|
||
}
|
||
|
||
func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||
inviteID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteUserInvite(inviteID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
// --- SERVER SETTINGS & SMTP ---
|
||
|
||
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
||
settings, err := s.store.GetSystemSettings()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||
writeJSON(w, http.StatusOK, settings)
|
||
}
|
||
|
||
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||
var settings domain.SystemSettings
|
||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||
writeJSON(w, http.StatusOK, settings)
|
||
}
|
||
|
||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
user, _ := s.store.GetUserByID(userID)
|
||
|
||
var req struct {
|
||
ToEmail string `json:"toEmail"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||
toEmail := strings.TrimSpace(req.ToEmail)
|
||
if toEmail == "" && user != nil {
|
||
toEmail = user.Email
|
||
}
|
||
if toEmail == "" {
|
||
writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich")
|
||
return
|
||
}
|
||
|
||
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
|
||
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"sent": true,
|
||
"toEmail": toEmail,
|
||
})
|
||
}
|
||
|
||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||
|
||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||
sys, _ := s.store.GetSystemSettings()
|
||
host := ""
|
||
port := "587"
|
||
user := ""
|
||
pass := ""
|
||
from := ""
|
||
useTLS := true
|
||
|
||
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
|
||
host = strings.TrimSpace(sys.SMTPHost)
|
||
port = strings.TrimSpace(sys.SMTPPort)
|
||
user = strings.TrimSpace(sys.SMTPUser)
|
||
pass = sys.SMTPPass
|
||
from = strings.TrimSpace(sys.SMTPFrom)
|
||
useTLS = sys.SMTPTLS
|
||
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
|
||
host = strings.TrimSpace(s.cfg.SMTPHost)
|
||
port = strings.TrimSpace(s.cfg.SMTPPort)
|
||
user = strings.TrimSpace(s.cfg.SMTPUser)
|
||
pass = s.cfg.SMTPPass
|
||
from = strings.TrimSpace(s.cfg.SMTPFrom)
|
||
useTLS = s.cfg.SMTPTLS
|
||
}
|
||
|
||
if host == "" {
|
||
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
|
||
}
|
||
if port == "" {
|
||
port = "587"
|
||
}
|
||
if from == "" {
|
||
if user != "" && strings.Contains(user, "@") {
|
||
from = user
|
||
} else {
|
||
from = "noreply@mindsift.local"
|
||
}
|
||
}
|
||
|
||
addr := fmt.Sprintf("%s:%s", host, port)
|
||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
|
||
|
||
var smtpAuth smtp.Auth
|
||
if user != "" || pass != "" {
|
||
smtpAuth = smtp.PlainAuth("", user, pass, host)
|
||
}
|
||
|
||
if port == "465" && useTLS {
|
||
tlsCfg := &tls.Config{ServerName: host}
|
||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer conn.Close()
|
||
client, err := smtp.NewClient(conn, host)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer client.Quit()
|
||
if smtpAuth != nil {
|
||
if err := client.Auth(smtpAuth); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := client.Mail(from); err != nil {
|
||
return err
|
||
}
|
||
if err := client.Rcpt(toEmail); err != nil {
|
||
return err
|
||
}
|
||
wc, err := client.Data()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = wc.Write([]byte(msg))
|
||
_ = wc.Close()
|
||
return err
|
||
}
|
||
|
||
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
|
||
}
|
||
|
||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||
payload := map[string]interface{}{
|
||
"content": "⏰ **MindSift Aufgaben-Alarm**",
|
||
"embeds": []map[string]interface{}{
|
||
{
|
||
"title": title,
|
||
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
|
||
"color": 2450411,
|
||
"footer": map[string]string{
|
||
"text": "MindSift Alarm-System",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||
payload := map[string]interface{}{
|
||
"event": event,
|
||
"title": title,
|
||
"notes": notes,
|
||
"dueDate": dueDate,
|
||
"priority": priority,
|
||
"timestamp": domain.NowISO(),
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) startAlarmTicker() {
|
||
ticker := time.NewTicker(60 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
for range ticker.C {
|
||
alarms, err := s.store.GetDueTasksForAlarm()
|
||
if err != nil || len(alarms) == 0 {
|
||
continue
|
||
}
|
||
|
||
today := time.Now().Format("2006-01-02")
|
||
|
||
for _, a := range alarms {
|
||
if a.DueDate > today {
|
||
continue
|
||
}
|
||
if len(a.AlarmChannels) == 0 {
|
||
continue
|
||
}
|
||
|
||
sentAny := false
|
||
desc := a.Notes
|
||
if desc == "" {
|
||
desc = "Aufgabe ist jetzt fällig."
|
||
}
|
||
|
||
for _, ch := range a.AlarmChannels {
|
||
switch ch {
|
||
case "discord":
|
||
if a.DiscordWebhookURL != "" {
|
||
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
case "webhook":
|
||
if a.CustomWebhookURL != "" {
|
||
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
case "email":
|
||
if a.AlarmEmail != "" {
|
||
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, a.DueDate, a.Priority, desc)
|
||
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if sentAny {
|
||
_ = s.store.MarkTaskAlarmSent(a.TaskID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|