MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m5s
Task list shares now carry a permission (editor/viewer). The server rejects writes from viewers with 403, and the UI hides or disables edit controls for read-only lists. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2961 lines
92 KiB
Go
2961 lines
92 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"log/slog"
|
||
"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(s.apiLoggingMiddleware)
|
||
r.Use(cors.Handler(cors.Options{
|
||
AllowedOrigins: []string{"*"},
|
||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||
ExposedHeaders: []string{"Link"},
|
||
AllowCredentials: true,
|
||
MaxAge: 300,
|
||
}))
|
||
|
||
// Public Health, Setup & Auth Routes
|
||
r.Get("/api/v1/health", s.handleHealth)
|
||
r.Get("/api/v1/auth/status", s.handleAuthStatus)
|
||
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
|
||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
||
r.Get("/api/v1/ws", s.handleWebSocket)
|
||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
||
r.Get("/api/v1/events", s.handleSSE)
|
||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||
|
||
// Authenticated API Routes
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.authMiddleware)
|
||
|
||
r.Get("/api/v1/auth/me", s.handleMe)
|
||
r.Post("/api/v1/auth/logout", s.handleLogout)
|
||
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
|
||
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.Get("/api/v1/thoughts/{id}", s.handleGetThought)
|
||
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
|
||
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
|
||
r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded)
|
||
|
||
// Tinder-Style Triage & Tasks
|
||
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
|
||
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
|
||
r.Get("/api/v1/tasks", s.handleGetTasks)
|
||
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
|
||
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
|
||
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
|
||
|
||
// Task Lists & Shares
|
||
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
|
||
r.Post("/api/v1/task-lists", s.handleCreateTaskList)
|
||
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
|
||
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
|
||
r.Get("/api/v1/task-lists/{id}/shares", s.handleGetTaskListShares)
|
||
r.Post("/api/v1/task-lists/{id}/shares", s.handleCreateTaskListShare)
|
||
r.Delete("/api/v1/task-lists/{id}/shares/{shareId}", s.handleDeleteTaskListShare)
|
||
|
||
// Workspace Documents & Folders
|
||
r.Get("/api/v1/folders", s.handleGetFolders)
|
||
r.Post("/api/v1/folders", s.handleCreateFolder)
|
||
r.Post("/api/v1/folders/move", s.handleMoveFolder)
|
||
r.Delete("/api/v1/folders", s.handleDeleteFolder)
|
||
|
||
r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders)
|
||
r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder)
|
||
r.Get("/api/v1/workspace/folders/shared/{id}", s.handleGetSharedFolderByID)
|
||
r.Delete("/api/v1/workspace/folders/shared/{id}", s.handleDeleteSharedFolder)
|
||
r.Put("/api/v1/workspace/folders/shared/{id}/mount", s.handleUpdateFolderMount)
|
||
r.Get("/api/v1/workspace/folders/shared/{id}/shares", s.handleGetFolderShares)
|
||
r.Post("/api/v1/workspace/folders/shared/{id}/shares", s.handleCreateFolderShare)
|
||
r.Delete("/api/v1/workspace/folders/shared/{id}/shares/{shareId}", s.handleDeleteFolderShare)
|
||
|
||
// Workspace Tags & Pages
|
||
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
|
||
|
||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
||
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
|
||
r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
|
||
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
|
||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
|
||
|
||
// 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)
|
||
|
||
// Shared Lists & Realtime Collaboration
|
||
r.Get("/api/v1/lists", s.handleGetAllLists)
|
||
r.Post("/api/v1/lists", s.handleCreateList)
|
||
r.Get("/api/v1/lists/{id}", s.handleGetListByID)
|
||
r.Delete("/api/v1/lists/{id}", s.handleDeleteList)
|
||
r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares)
|
||
r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare)
|
||
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
|
||
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
|
||
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
||
r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
|
||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
|
||
|
||
// Organizations & Teams
|
||
r.Get("/api/v1/organizations", s.handleGetOrganizations)
|
||
r.Post("/api/v1/organizations", s.handleCreateOrganization)
|
||
r.Delete("/api/v1/organizations/{id}", s.handleDeleteOrganization)
|
||
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
|
||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
||
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
|
||
r.Put("/api/v1/teams/{id}", s.handleUpdateTeam)
|
||
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
|
||
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
|
||
r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole)
|
||
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||
|
||
// API Keys Management
|
||
r.Get("/api/v1/keys", s.handleGetAPIKeys)
|
||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
|
||
|
||
// Staff Routes (Server-Admin & Team-Admin)
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.staffMiddleware)
|
||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
|
||
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
|
||
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
|
||
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
|
||
|
||
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
|
||
})
|
||
|
||
// Server-Admin Strictly Protected Routes
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(s.adminMiddleware)
|
||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
|
||
})
|
||
})
|
||
|
||
// Embedded Frontend SPA Handler
|
||
if s.staticFS != nil {
|
||
fileServer := http.FileServer(http.FS(s.staticFS))
|
||
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
|
||
path := strings.TrimPrefix(req.URL.Path, "/")
|
||
if path == "" {
|
||
path = "index.html"
|
||
}
|
||
if _, err := fs.Stat(s.staticFS, path); err != nil {
|
||
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
|
||
http.NotFound(w, req)
|
||
return
|
||
}
|
||
req.URL.Path = "/"
|
||
path = "index.html"
|
||
}
|
||
if path == "index.html" || path == "sw.js" || path == "manifest.json" {
|
||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||
w.Header().Set("Pragma", "no-cache")
|
||
w.Header().Set("Expires", "0")
|
||
}
|
||
fileServer.ServeHTTP(w, req)
|
||
})
|
||
}
|
||
|
||
return r
|
||
}
|
||
|
||
// --- HELPERS & MIDDLEWARE ---
|
||
|
||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(data)
|
||
}
|
||
|
||
func writeError(w http.ResponseWriter, status int, message string) {
|
||
writeJSON(w, status, map[string]string{"error": message})
|
||
}
|
||
|
||
// writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status.
|
||
func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) {
|
||
if errors.Is(err, storage.ErrTaskListReadOnly) {
|
||
writeError(w, http.StatusForbidden, err.Error())
|
||
return
|
||
}
|
||
writeError(w, fallbackStatus, err.Error())
|
||
}
|
||
|
||
func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// Only log API requests (/api/...)
|
||
if !strings.HasPrefix(r.URL.Path, "/api") {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
start := time.Now()
|
||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||
|
||
next.ServeHTTP(ww, r)
|
||
|
||
duration := time.Since(start)
|
||
durationMs := float64(duration.Microseconds()) / 1000.0
|
||
status := ww.Status()
|
||
if status == 0 {
|
||
status = http.StatusOK
|
||
}
|
||
|
||
method := r.Method
|
||
path := r.URL.Path
|
||
|
||
if status >= 500 {
|
||
slog.Error("HTTP request failed",
|
||
"method", method,
|
||
"path", path,
|
||
"status", status,
|
||
"duration_ms", durationMs,
|
||
)
|
||
} else if status >= 400 {
|
||
slog.Warn("HTTP request warning",
|
||
"method", method,
|
||
"path", path,
|
||
"status", status,
|
||
"duration_ms", durationMs,
|
||
)
|
||
} else {
|
||
slog.Debug("HTTP request completed",
|
||
"method", method,
|
||
"path", path,
|
||
"status", status,
|
||
"duration_ms", durationMs,
|
||
)
|
||
}
|
||
})
|
||
}
|
||
|
||
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 {
|
||
if !s.store.IsTokenRevoked(tokenStr) {
|
||
user, userErr := s.store.GetUserByID(claims.UserID)
|
||
if userErr == nil && user != nil && user.IsActive {
|
||
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
|
||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Check X-API-Key Header
|
||
apiKey := r.Header.Get("X-API-Key")
|
||
if apiKey != "" {
|
||
user, err := s.store.ValidateAPIKeyAndGetUser(apiKey)
|
||
if err == nil && user != nil && user.IsActive {
|
||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||
return
|
||
}
|
||
}
|
||
|
||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||
})
|
||
}
|
||
|
||
func (s *Server) staffMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
if userID == "" {
|
||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||
return
|
||
}
|
||
user, err := s.store.GetUserByID(userID)
|
||
if err != nil || user == nil || user.Role != "admin" {
|
||
writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func (s *Server) adminMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
if userID == "" {
|
||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||
return
|
||
}
|
||
user, err := s.store.GetUserByID(userID)
|
||
if err != nil || user == nil || user.Role != "admin" {
|
||
writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// --- HANDLERS ---
|
||
|
||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"status": "ok",
|
||
"service": "mindsift",
|
||
"time": domain.NowISO(),
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||
count, _ := s.store.CountUsers()
|
||
sys, _ := s.store.GetSystemSettings()
|
||
allowReg := false
|
||
instanceName := "MindSift"
|
||
if sys != nil {
|
||
allowReg = sys.AllowRegistration
|
||
if sys.InstanceName != "" {
|
||
instanceName = sys.InstanceName
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"needsSetup": count == 0,
|
||
"allowRegistration": allowReg,
|
||
"instanceName": instanceName,
|
||
"demoMode": s.cfg.DemoMode,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||
count, err := s.store.CountUsers()
|
||
if err != nil || count > 0 {
|
||
writeError(w, http.StatusForbidden, "Ersteinrichtung wurde bereits abgeschlossen")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
DisplayName string `json:"displayName"`
|
||
InstanceName string `json:"instanceName"`
|
||
AllowRegistration bool `json:"allowRegistration"`
|
||
SharingMode string `json:"sharingMode"`
|
||
SeedSampleData bool `json:"seedSampleData"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.DisplayName) == "" {
|
||
req.DisplayName = "Administrator"
|
||
}
|
||
|
||
hash, err := auth.HashPassword(req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
|
||
return
|
||
}
|
||
|
||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, "admin")
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
sys, _ := s.store.GetSystemSettings()
|
||
if sys != nil {
|
||
if strings.TrimSpace(req.InstanceName) != "" {
|
||
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
||
}
|
||
sys.AllowRegistration = req.AllowRegistration
|
||
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
|
||
sys.SharingMode = req.SharingMode
|
||
}
|
||
_ = s.store.UpdateSystemSettings(sys)
|
||
}
|
||
|
||
if req.SeedSampleData {
|
||
_ = s.store.SeedStarterContent(user)
|
||
}
|
||
|
||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||
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, user.TokenVersion)
|
||
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 {
|
||
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
|
||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||
return
|
||
}
|
||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
|
||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||
return
|
||
}
|
||
if !user.IsActive {
|
||
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
|
||
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
|
||
return
|
||
}
|
||
|
||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||
if err != nil {
|
||
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
|
||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||
return
|
||
}
|
||
|
||
slog.Info("Login erfolgreich", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "role", user.Role, "remote_addr", r.RemoteAddr)
|
||
|
||
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
|
||
}
|
||
|
||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||
if source == "" {
|
||
source = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source")))
|
||
}
|
||
if source == "" {
|
||
source = strings.ToLower(strings.TrimSpace(r.Header.Get("X-Source")))
|
||
}
|
||
if source == "" {
|
||
// When called via API key or webhook token, automatically default source to "webhook"
|
||
if r.Header.Get("X-API-Key") != "" ||
|
||
strings.HasPrefix(r.Header.Get("Authorization"), "Bearer mindsift_sec_") ||
|
||
strings.HasPrefix(r.URL.Query().Get("token"), "mindsift_sec_") {
|
||
source = "webhook"
|
||
} else {
|
||
source = "manual"
|
||
}
|
||
}
|
||
|
||
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
|
||
if err != nil {
|
||
writeStoreError(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
|
||
target := strings.TrimSpace(req.Target)
|
||
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
|
||
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
|
||
thought = converted
|
||
if target == "list_item" && targetListID != "" {
|
||
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
|
||
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought)
|
||
writeJSON(w, http.StatusCreated, thought)
|
||
}
|
||
|
||
func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
status := r.URL.Query().Get("status")
|
||
thoughts, err := s.store.GetThoughts(userID, status, 250)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, thoughts)
|
||
}
|
||
|
||
func (s *Server) handleGetThought(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
thoughtID := chi.URLParam(r, "id")
|
||
thought, err := s.store.GetThoughtByID(userID, thoughtID)
|
||
if err != nil || thought == nil {
|
||
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, thought)
|
||
}
|
||
|
||
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 {
|
||
writeStoreError(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
|
||
// If converted to a list item, broadcast via WebSocket to the space
|
||
if convertTo == "list_item" && targetListID != "" {
|
||
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
|
||
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
|
||
}
|
||
}
|
||
|
||
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
||
writeJSON(w, http.StatusOK, updated)
|
||
}
|
||
|
||
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
taskListID := r.URL.Query().Get("taskListId")
|
||
tasks, err := s.store.GetTasks(userID, taskListID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, tasks)
|
||
}
|
||
|
||
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
taskListID := chi.URLParam(r, "id")
|
||
if taskListID == "" {
|
||
taskListID = r.URL.Query().Get("taskListId")
|
||
}
|
||
|
||
var req struct {
|
||
Items []domain.ThoughtReorderItem `json:"items"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
|
||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||
return
|
||
}
|
||
for _, it := range req.Items {
|
||
if th, err := s.store.GetThoughtByID(userID, it.ID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
|
||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||
return
|
||
}
|
||
}
|
||
|
||
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{
|
||
"taskListId": taskListID,
|
||
"items": req.Items,
|
||
"userId": userID,
|
||
})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
thoughtID := chi.URLParam(r, "id")
|
||
|
||
var req struct {
|
||
Content *string `json:"content"`
|
||
IsCompleted *bool `json:"isCompleted"`
|
||
DueDate *string `json:"dueDate"`
|
||
Priority *string `json:"priority"`
|
||
TaskListID *string `json:"taskListId"`
|
||
KanbanStatus *string `json:"kanbanStatus"`
|
||
Notes *string `json:"notes"`
|
||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||
Tags *[]string `json:"tags"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
||
if err != nil {
|
||
writeStoreError(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
|
||
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
|
||
writeJSON(w, http.StatusOK, updated)
|
||
}
|
||
|
||
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
thoughtID := chi.URLParam(r, "id")
|
||
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
|
||
}
|
||
|
||
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
thoughtID := chi.URLParam(r, "id")
|
||
if th, err := s.store.GetThoughtByID(userID, thoughtID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
|
||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||
return
|
||
}
|
||
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
||
writeStoreError(w, http.StatusInternalServerError, err)
|
||
return
|
||
}
|
||
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
count, err := s.store.PurgeDiscardedThoughts(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]int64{"purged": count})
|
||
}
|
||
|
||
// --- DOCUMENTS ---
|
||
|
||
func (s *Server) broadcastDocToAuthorizedUsers(docID, event string) {
|
||
users, err := s.store.GetAuthorizedDocUsers(docID)
|
||
if err != nil {
|
||
return
|
||
}
|
||
var firstDoc *domain.Document
|
||
for _, uid := range users {
|
||
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
|
||
if firstDoc == nil {
|
||
firstDoc = userDoc
|
||
}
|
||
s.wsHub.BroadcastToUser(uid, event, userDoc)
|
||
}
|
||
}
|
||
if s.wsHub != nil && firstDoc != nil {
|
||
s.wsHub.BroadcastRoom("doc:"+docID, event, firstDoc, nil)
|
||
}
|
||
}
|
||
|
||
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.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
|
||
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
|
||
}
|
||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||
}
|
||
writeJSON(w, http.StatusOK, doc)
|
||
}
|
||
|
||
func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
|
||
var req domain.DocBlocksPatchRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
doc, mergedOrder, err := s.store.PatchDocumentBlocks(userID, docID, req)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
patchEvent := domain.DocBlocksPatchEvent{
|
||
DocID: docID,
|
||
UpdatedBy: userID,
|
||
UpdatedAt: doc.UpdatedAt,
|
||
Upserted: req.Upserted,
|
||
DeletedIDs: req.DeletedIDs,
|
||
BlockOrder: mergedOrder,
|
||
}
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_BLOCKS_PATCHED", patchEvent)
|
||
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
||
for _, uid := range users {
|
||
s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent)
|
||
}
|
||
}
|
||
|
||
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")
|
||
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
|
||
|
||
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
for _, uid := range authorizedUsers {
|
||
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
blockID := chi.URLParam(r, "blockId")
|
||
|
||
var req struct {
|
||
Content string `json:"content"`
|
||
Checked bool `json:"checked"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
|
||
writeJSON(w, http.StatusCreated, thought)
|
||
}
|
||
|
||
// --- 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.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,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
SourcePath string `json:"sourcePath"`
|
||
TargetParentPath string `json:"targetParentPath"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
newPath, movedDocIDs, err := s.store.MoveFolder(userID, req.SourcePath, req.TargetParentPath)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
for _, id := range movedDocIDs {
|
||
if doc, err := s.store.GetDocumentByID(userID, id); err == nil && doc != nil {
|
||
s.wsHub.BroadcastDocEvent(id, "DOC_UPDATED", doc)
|
||
s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED")
|
||
}
|
||
}
|
||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{
|
||
"sourcePath": req.SourcePath,
|
||
"newPath": newPath,
|
||
})
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"newPath": newPath,
|
||
"movedDocIds": movedDocIDs,
|
||
})
|
||
}
|
||
|
||
// --- WORKSPACE SHARED FOLDERS ---
|
||
|
||
func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
folders, err := s.store.GetSharedFolders(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, folders)
|
||
}
|
||
|
||
func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
MountPath string `json:"mountPath"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
folder, err := s.store.CreateSharedFolder(userID, req.Name, req.MountPath)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", folder)
|
||
writeJSON(w, http.StatusCreated, folder)
|
||
}
|
||
|
||
func (s *Server) handleGetSharedFolderByID(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
folderID := chi.URLParam(r, "id")
|
||
folder, err := s.store.GetSharedFolderByID(userID, folderID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, folder)
|
||
}
|
||
|
||
func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
folderID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
folderID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
MountPath string `json:"mountPath"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if err := s.store.UpdateFolderMount(userID, folderID, req.MountPath); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
|
||
}
|
||
|
||
func (s *Server) handleGetFolderShares(w http.ResponseWriter, r *http.Request) {
|
||
folderID := chi.URLParam(r, "id")
|
||
shares, err := s.store.GetFolderShares(folderID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, shares)
|
||
}
|
||
|
||
func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
folderID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
TargetType string `json:"targetType"` // team | user
|
||
TargetID string `json:"targetId"`
|
||
UserId string `json:"userId"`
|
||
Email string `json:"email"`
|
||
TeamId string `json:"teamId"`
|
||
Permission string `json:"permission"` // read | write
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
targetType := req.TargetType
|
||
targetIdentifier := req.TargetID
|
||
if targetIdentifier == "" {
|
||
if req.TeamId != "" {
|
||
targetType = "team"
|
||
targetIdentifier = req.TeamId
|
||
} else if req.UserId != "" {
|
||
targetType = "user"
|
||
targetIdentifier = req.UserId
|
||
} else if req.Email != "" {
|
||
targetType = "user"
|
||
targetIdentifier = req.Email
|
||
}
|
||
}
|
||
|
||
share, err := s.store.CreateFolderShare(userID, folderID, targetType, targetIdentifier, req.Permission)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share)
|
||
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
|
||
for _, docID := range docIDs {
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusCreated, share)
|
||
}
|
||
|
||
func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
folderID := chi.URLParam(r, "id")
|
||
shareID := chi.URLParam(r, "shareId")
|
||
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
|
||
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID})
|
||
for _, docID := range docIDs {
|
||
s.wsHub.BroadcastToUser(shareID, "DOC_DELETED", map[string]string{"id": docID})
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
FolderPath string `json:"folderPath"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
doc, err := s.store.MoveDocument(userID, docID, req.FolderPath)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil)
|
||
writeJSON(w, http.StatusOK, doc)
|
||
}
|
||
|
||
// --- WORKSPACE TAGS & PAGES ---
|
||
|
||
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
tags, err := s.store.GetWorkspaceTags(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, tags)
|
||
}
|
||
|
||
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
tag := chi.URLParam(r, "tag")
|
||
details, err := s.store.GetTagDetails(userID, tag)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, details)
|
||
}
|
||
|
||
func (s *Server) handleUpdateTagPageNote(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
tag := chi.URLParam(r, "tag")
|
||
var req struct {
|
||
Content string `json:"content"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
updatedAt, err := s.store.UpdateTagPageNote(userID, tag, req.Content)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]string{
|
||
"tag": tag,
|
||
"content": req.Content,
|
||
"updatedAt": updatedAt,
|
||
})
|
||
}
|
||
|
||
// --- DOCUMENT SHARING & COLLABORATION ---
|
||
|
||
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
||
docID := chi.URLParam(r, "id")
|
||
shares, err := s.store.GetDocShares(docID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, shares)
|
||
}
|
||
|
||
func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
docID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
UserID string `json:"userId"`
|
||
Email string `json:"email"`
|
||
TeamID string `json:"teamId"`
|
||
Permission string `json:"permission"` // read | write
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
if req.TeamID != "" {
|
||
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||
writeJSON(w, http.StatusCreated, share)
|
||
return
|
||
}
|
||
|
||
target := req.UserID
|
||
if target == "" {
|
||
target = req.Email
|
||
}
|
||
if target == "" {
|
||
writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
|
||
return
|
||
}
|
||
|
||
share, err := s.store.CreateDocShare(userID, docID, target, req.Permission)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||
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_DELETED", map[string]string{"id": docID})
|
||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||
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)
|
||
}
|
||
|
||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||
token := r.URL.Query().Get("token")
|
||
if token == "" {
|
||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||
}
|
||
}
|
||
if token != "" {
|
||
if strings.HasPrefix(token, "mindsift_sec_") {
|
||
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
|
||
return user.ID
|
||
}
|
||
}
|
||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
|
||
if !s.store.IsTokenRevoked(token) {
|
||
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
|
||
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
|
||
return claims.UserID
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// --- REALTIME LISTS & SHARING ---
|
||
|
||
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
lists, err := s.store.GetAllListsForUser(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, lists)
|
||
}
|
||
|
||
func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
lst, err := s.store.GetListByID(userID, listID)
|
||
if err != nil {
|
||
writeError(w, http.StatusNotFound, "Liste nicht gefunden")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, lst)
|
||
}
|
||
|
||
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
Title string `json:"title"`
|
||
Icon string `json:"icon"`
|
||
ListType string `json:"listType"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
|
||
writeJSON(w, http.StatusCreated, lst)
|
||
}
|
||
|
||
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteList(userID, listID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
shares, err := s.store.GetListShares(listID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, shares)
|
||
}
|
||
|
||
func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
|
||
currentUserID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
|
||
var req struct {
|
||
TargetType string `json:"targetType"` // "user" or "team"
|
||
TargetID string `json:"targetId"` // userId/email or teamId
|
||
Role string `json:"role"` // "editor" or "viewer"
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
targetType := strings.ToLower(strings.TrimSpace(req.TargetType))
|
||
if targetType != "team" {
|
||
targetType = "user"
|
||
}
|
||
|
||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||
if role != "viewer" && role != "editor" {
|
||
role = "editor"
|
||
}
|
||
|
||
share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
if targetType == "team" {
|
||
members, _ := s.store.GetTeamMembers(share.TargetID)
|
||
for _, tm := range members {
|
||
s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share)
|
||
}
|
||
} else {
|
||
s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share)
|
||
}
|
||
s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share)
|
||
|
||
writeJSON(w, http.StatusCreated, share)
|
||
}
|
||
|
||
func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
|
||
currentUserID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
shareID := chi.URLParam(r, "shareId")
|
||
|
||
if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{
|
||
"listId": listID,
|
||
"shareId": shareID,
|
||
})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
Content string `json:"content"`
|
||
Category string `json:"category"`
|
||
Quantity string `json:"quantity"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||
writeJSON(w, http.StatusCreated, item)
|
||
}
|
||
|
||
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
itemID := chi.URLParam(r, "itemId")
|
||
|
||
var req struct {
|
||
IsCompleted bool `json:"isCompleted"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
user, _ := s.store.GetUserByID(userID)
|
||
userName := "Mitglied"
|
||
if user != nil {
|
||
userName = user.DisplayName
|
||
}
|
||
|
||
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||
writeJSON(w, http.StatusOK, item)
|
||
}
|
||
|
||
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
itemID := chi.URLParam(r, "itemId")
|
||
|
||
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.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", item)
|
||
writeJSON(w, http.StatusOK, item)
|
||
}
|
||
|
||
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
itemID := chi.URLParam(r, "itemId")
|
||
|
||
_, err := s.store.DeleteListItem(itemID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
|
||
"itemId": itemID,
|
||
"listId": listID,
|
||
})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
removed, err := s.store.ClearCompletedListItems(listID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
|
||
"listId": listID,
|
||
"removed": removed,
|
||
})
|
||
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
|
||
}
|
||
|
||
func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) {
|
||
listID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
Items []domain.ListItemReorderItem `json:"items"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Invalid request body")
|
||
return
|
||
}
|
||
|
||
if err := s.store.ReorderListItems(listID, req.Items); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{
|
||
"listId": listID,
|
||
"items": req.Items,
|
||
})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||
// Some corporate proxies and reverse proxies (e.g. OpenResty/Nginx or HTTP/2 intermediaries)
|
||
// strip or alter the Connection header (e.g. "close" or missing) while passing
|
||
// Sec-WebSocket-Key or Upgrade: websocket. Normalize it so Gorilla Upgrader succeeds.
|
||
if r.Header.Get("Sec-Websocket-Key") != "" {
|
||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
|
||
r.Header.Set("Connection", "Upgrade")
|
||
}
|
||
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
|
||
r.Header.Set("Upgrade", "websocket")
|
||
}
|
||
}
|
||
|
||
roomID := chi.URLParam(r, "listId")
|
||
if roomID == "" {
|
||
roomID = chi.URLParam(r, "spaceId")
|
||
}
|
||
if roomID == "" {
|
||
roomID = r.URL.Query().Get("listId")
|
||
}
|
||
if roomID == "" {
|
||
roomID = r.URL.Query().Get("spaceId")
|
||
}
|
||
|
||
userID := s.authenticateUserFromReq(r)
|
||
if userID == "" {
|
||
userID = "anonymous"
|
||
}
|
||
s.wsHub.ServeWS(w, r, roomID, userID)
|
||
}
|
||
|
||
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||
roomID := r.URL.Query().Get("listId")
|
||
if roomID == "" {
|
||
roomID = r.URL.Query().Get("spaceId")
|
||
}
|
||
|
||
userID := s.authenticateUserFromReq(r)
|
||
if userID == "" {
|
||
userID = "anonymous"
|
||
}
|
||
|
||
var initialRooms []string
|
||
if roomsParam := r.URL.Query().Get("rooms"); roomsParam != "" {
|
||
for _, rm := range strings.Split(roomsParam, ",") {
|
||
rm = strings.TrimSpace(rm)
|
||
if rm != "" {
|
||
initialRooms = append(initialRooms, rm)
|
||
}
|
||
}
|
||
}
|
||
if docID := r.URL.Query().Get("docId"); docID != "" {
|
||
initialRooms = append(initialRooms, "doc:"+strings.TrimSpace(docID))
|
||
}
|
||
|
||
s.wsHub.ServeSSE(w, r, roomID, userID, initialRooms)
|
||
}
|
||
|
||
func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
SSEID string `json:"sseId"`
|
||
Room string `json:"room"`
|
||
DocID string `json:"docId"`
|
||
Action string `json:"action"` // "join" or "leave"
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Room == "" && req.DocID != "" {
|
||
req.Room = "doc:" + req.DocID
|
||
}
|
||
if req.SSEID == "" || req.Room == "" {
|
||
writeError(w, http.StatusBadRequest, "sseId und room erforderlich")
|
||
return
|
||
}
|
||
|
||
if req.Action == "leave" {
|
||
s.wsHub.LeaveSSERoomByID(req.SSEID, req.Room)
|
||
} else {
|
||
s.wsHub.JoinSSERoomByID(req.SSEID, req.Room)
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
// --- 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"`
|
||
Text string `json:"text"`
|
||
Message string `json:"message"`
|
||
Priority string `json:"priority"`
|
||
Source string `json:"source"`
|
||
Tags []string `json:"tags"`
|
||
DueDate string `json:"dueDate"`
|
||
DueDate2 string `json:"due_date"`
|
||
}
|
||
|
||
bodyBytes, _ := io.ReadAll(r.Body)
|
||
if len(bodyBytes) > 0 {
|
||
if jsonErr := json.Unmarshal(bodyBytes, &req); jsonErr != nil {
|
||
plain := strings.TrimSpace(string(bodyBytes))
|
||
if plain != "" {
|
||
req.Content = plain
|
||
}
|
||
}
|
||
}
|
||
|
||
if req.Content == "" {
|
||
if req.Text != "" {
|
||
req.Content = req.Text
|
||
} else if req.Message != "" {
|
||
req.Content = req.Message
|
||
}
|
||
}
|
||
|
||
dueDate := req.DueDate
|
||
if dueDate == "" {
|
||
dueDate = req.DueDate2
|
||
}
|
||
|
||
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', 'content', 'text' oder 'message' 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"
|
||
}
|
||
|
||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||
if source == "" {
|
||
source = "webhook"
|
||
}
|
||
|
||
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought)
|
||
|
||
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) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
authHeader := r.Header.Get("Authorization")
|
||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
|
||
var exp time.Time
|
||
if claims.ExpiresAt != nil {
|
||
exp = claims.ExpiresAt.Time
|
||
} else {
|
||
exp = time.Now().Add(30 * 24 * time.Hour)
|
||
}
|
||
_ = s.store.RevokeToken(tokenStr, userID, exp)
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
if err := s.store.RevokeAllUserTokens(userID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
DisplayName *string `json:"displayName"`
|
||
Email *string `json:"email"`
|
||
Password *string `json:"password"`
|
||
AvatarURL *string `json:"avatarUrl"`
|
||
AlarmChannels *[]string `json:"alarmChannels"`
|
||
AlarmEmail *string `json:"alarmEmail"`
|
||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||
CustomWebhookURL *string `json:"customWebhookUrl"`
|
||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||
hash, err := auth.HashPassword(*req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
|
||
return
|
||
}
|
||
_ = s.store.UpdateUserPassword(userID, hash)
|
||
}
|
||
u, err := s.store.UpdateUserFull(
|
||
userID,
|
||
req.DisplayName,
|
||
req.Email,
|
||
nil,
|
||
nil,
|
||
req.AlarmChannels,
|
||
req.AlarmEmail,
|
||
req.DiscordWebhookURL,
|
||
req.CustomWebhookURL,
|
||
req.NotifyBeforeMinutes,
|
||
req.AvatarURL,
|
||
)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, u)
|
||
}
|
||
|
||
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
user, err := s.store.GetUserByID(userID)
|
||
if err != nil || user == nil {
|
||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Channel string `json:"channel"` // email | discord | webhook
|
||
Target string `json:"target"` // optional override URL or email
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
|
||
channel := strings.ToLower(strings.TrimSpace(req.Channel))
|
||
target := strings.TrimSpace(req.Target)
|
||
|
||
switch channel {
|
||
case "discord":
|
||
url := target
|
||
if url == "" {
|
||
url = user.DiscordWebhookURL
|
||
}
|
||
if url == "" {
|
||
writeError(w, http.StatusBadRequest, "Bitte eine Discord Webhook URL angeben")
|
||
return
|
||
}
|
||
if err := s.sendDiscordNotification(url, "Test-Alarm von MindSift", "Dein Discord-Alarmkanal funktioniert einwandfrei!", domain.NowISO()[:10], "normal"); err != nil {
|
||
writeError(w, http.StatusBadGateway, "Discord-Fehler: "+err.Error())
|
||
return
|
||
}
|
||
case "webhook":
|
||
url := target
|
||
if url == "" {
|
||
url = user.CustomWebhookURL
|
||
}
|
||
if url == "" {
|
||
writeError(w, http.StatusBadRequest, "Bitte eine Webhook-URL angeben")
|
||
return
|
||
}
|
||
if err := s.sendCustomWebhookNotification(url, "test_alarm", "Test-Alarm von MindSift", "Dein eigener Webhook empfängt MindSift-Alarme!", domain.NowISO()[:10], "normal"); err != nil {
|
||
writeError(w, http.StatusBadGateway, "Webhook-Fehler: "+err.Error())
|
||
return
|
||
}
|
||
case "email":
|
||
toEmail := target
|
||
if toEmail == "" {
|
||
toEmail = user.AlarmEmail
|
||
}
|
||
if toEmail == "" {
|
||
toEmail = user.Email
|
||
}
|
||
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
|
||
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
|
||
return
|
||
}
|
||
default:
|
||
writeError(w, http.StatusBadRequest, "Unbekannter Alarm-Kanal (erlaubt: email, discord, webhook)")
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"sent": true,
|
||
"channel": channel,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
users, err := s.store.GetDiscoverableUsers(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, users)
|
||
}
|
||
|
||
// --- TASK LISTS ---
|
||
|
||
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
lists, err := s.store.GetTaskLists(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, lists)
|
||
}
|
||
|
||
func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
SpaceID string `json:"spaceId"`
|
||
TeamID string `json:"teamId"`
|
||
Title string `json:"title"`
|
||
Icon string `json:"icon"`
|
||
Color string `json:"color"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, tl)
|
||
}
|
||
|
||
func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
Title *string `json:"title"`
|
||
Icon *string `json:"icon"`
|
||
Color *string `json:"color"`
|
||
TeamID *string `json:"teamId"`
|
||
SpaceID *string `json:"spaceId"`
|
||
Columns *[]domain.KanbanColumn `json:"columns"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
|
||
if err != nil {
|
||
writeStoreError(w, http.StatusBadRequest, err)
|
||
return
|
||
}
|
||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
|
||
writeJSON(w, http.StatusOK, tl)
|
||
}
|
||
|
||
func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteTaskList(userID, listID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
s.wsHub.BroadcastToAll("TASK_LIST_DELETED", map[string]string{"id": listID})
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleGetTaskListShares(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
if _, err := s.store.GetTaskListByID(userID, listID); err != nil {
|
||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden oder kein Zugriff")
|
||
return
|
||
}
|
||
shares, err := s.store.GetTaskListShares(listID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, shares)
|
||
}
|
||
|
||
func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
tl, err := s.store.GetTaskListByID(userID, listID)
|
||
if err != nil || tl == nil {
|
||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
|
||
return
|
||
}
|
||
if tl.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann die Liste freigeben")
|
||
return
|
||
}
|
||
var req struct {
|
||
TargetType string `json:"targetType"` // team | user
|
||
TargetID string `json:"targetId"`
|
||
Permission string `json:"permission"` // editor | viewer
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
permission := strings.ToLower(strings.TrimSpace(req.Permission))
|
||
if permission != "viewer" {
|
||
permission = "editor"
|
||
}
|
||
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, permission, userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
updatedList, _ := s.store.GetTaskListByID(userID, listID)
|
||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
|
||
writeJSON(w, http.StatusCreated, share)
|
||
}
|
||
|
||
func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
listID := chi.URLParam(r, "id")
|
||
shareID := chi.URLParam(r, "shareId")
|
||
tl, err := s.store.GetTaskListByID(userID, listID)
|
||
if err != nil || tl == nil {
|
||
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
|
||
return
|
||
}
|
||
if tl.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen")
|
||
return
|
||
}
|
||
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
updatedList, _ := s.store.GetTaskListByID(userID, listID)
|
||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
// --- ORGANIZATIONS & TEAMS ---
|
||
|
||
func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
orgs, err := s.store.GetOrganizationsForUser(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, orgs)
|
||
}
|
||
|
||
func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
org, err := s.store.CreateOrganization(userID, req.Name)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, org)
|
||
}
|
||
|
||
func (s *Server) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
||
orgID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteOrganization(orgID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
|
||
orgID := chi.URLParam(r, "id")
|
||
teams, err := s.store.GetTeamsForOrg(orgID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, teams)
|
||
}
|
||
|
||
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
orgID := chi.URLParam(r, "id")
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
team, err := s.store.CreateTeam(orgID, req.Name, req.Description)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
// Automatically add creator to team
|
||
_ = s.store.AddTeamMember(team.ID, userID, "admin")
|
||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||
writeJSON(w, http.StatusCreated, team)
|
||
}
|
||
|
||
func (s *Server) isTeamAdmin(userID, teamID string) bool {
|
||
if userID == "" || teamID == "" {
|
||
return false
|
||
}
|
||
role, err := s.store.GetTeamMemberRole(teamID, userID)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return role == "admin"
|
||
}
|
||
|
||
func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
OrgID string `json:"orgId"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
|
||
return
|
||
}
|
||
orgID := strings.TrimSpace(req.OrgID)
|
||
if orgID == "" {
|
||
orgID = "default"
|
||
}
|
||
team, err := s.store.CreateTeam(orgID, name, req.Description)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
// Automatically add creator to team as group admin
|
||
_ = s.store.AddTeamMember(team.ID, userID, "admin")
|
||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||
writeJSON(w, http.StatusCreated, team)
|
||
}
|
||
|
||
func (s *Server) handleUpdateTeam(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
teamID := chi.URLParam(r, "id")
|
||
if !s.isTeamAdmin(userID, teamID) {
|
||
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe bearbeiten")
|
||
return
|
||
}
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
|
||
return
|
||
}
|
||
updated, err := s.store.UpdateTeam(teamID, name, req.Description)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, updated)
|
||
}
|
||
|
||
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
teamID := chi.URLParam(r, "id")
|
||
if !s.isTeamAdmin(userID, teamID) {
|
||
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe löschen")
|
||
return
|
||
}
|
||
if err := s.store.DeleteTeam(teamID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
teams, err := s.store.GetTeamsForUser(userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, teams)
|
||
}
|
||
|
||
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
teamID := chi.URLParam(r, "id")
|
||
if !s.isTeamAdmin(userID, teamID) {
|
||
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitglieder hinzufügen")
|
||
return
|
||
}
|
||
var req struct {
|
||
UserID string `json:"userId"`
|
||
Email string `json:"email"`
|
||
Role string `json:"role"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
targetUserID := strings.TrimSpace(req.UserID)
|
||
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
|
||
u, err := s.store.GetUserByEmail(req.Email)
|
||
if err != nil || u == nil {
|
||
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
|
||
return
|
||
}
|
||
targetUserID = u.ID
|
||
}
|
||
if targetUserID == "" {
|
||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||
return
|
||
}
|
||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||
if role != "admin" && role != "member" {
|
||
role = "member"
|
||
}
|
||
if err := s.store.AddTeamMember(teamID, targetUserID, role); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
members, _ := s.store.GetTeamMembers(teamID)
|
||
writeJSON(w, http.StatusOK, members)
|
||
}
|
||
|
||
func (s *Server) handleUpdateTeamMemberRole(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
teamID := chi.URLParam(r, "id")
|
||
memberUserID := chi.URLParam(r, "userId")
|
||
if !s.isTeamAdmin(userID, teamID) {
|
||
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitgliedsrollen ändern")
|
||
return
|
||
}
|
||
var req struct {
|
||
Role string `json:"role"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||
if role != "admin" && role != "member" {
|
||
writeError(w, http.StatusBadRequest, "Ungültige Rolle (erlaubt sind: admin, member)")
|
||
return
|
||
}
|
||
if err := s.store.UpdateTeamMemberRole(teamID, memberUserID, role); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
members, _ := s.store.GetTeamMembers(teamID)
|
||
writeJSON(w, http.StatusOK, members)
|
||
}
|
||
|
||
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
teamID := chi.URLParam(r, "id")
|
||
memberUserID := chi.URLParam(r, "userId")
|
||
|
||
isSelf := userID == memberUserID
|
||
isAdmin := s.isTeamAdmin(userID, teamID)
|
||
|
||
if !isSelf && !isAdmin {
|
||
writeError(w, http.StatusForbidden, "Nicht berechtigt, dieses Mitglied zu entfernen")
|
||
return
|
||
}
|
||
|
||
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
||
}
|
||
|
||
// --- ADMIN & STAFF PANEL ---
|
||
|
||
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||
users, err := s.store.GetAllUsers()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, users)
|
||
}
|
||
|
||
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||
callerID := auth.GetUserID(r.Context())
|
||
caller, _ := s.store.GetUserByID(callerID)
|
||
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
DisplayName string `json:"displayName"`
|
||
Role string `json:"role"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Email == "" || req.Password == "" {
|
||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
|
||
return
|
||
}
|
||
// Only full server admin can create another server admin
|
||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||
req.Role = "member"
|
||
}
|
||
if strings.TrimSpace(req.DisplayName) == "" {
|
||
req.DisplayName = strings.Split(req.Email, "@")[0]
|
||
}
|
||
|
||
hash, err := auth.HashPassword(req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||
return
|
||
}
|
||
u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, u)
|
||
}
|
||
|
||
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||
callerID := auth.GetUserID(r.Context())
|
||
caller, _ := s.store.GetUserByID(callerID)
|
||
targetUserID := chi.URLParam(r, "id")
|
||
targetUser, err := s.store.GetUserByID(targetUserID)
|
||
if err != nil || targetUser == nil {
|
||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||
return
|
||
}
|
||
|
||
// Team-Admins cannot modify Server-Admins
|
||
if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" {
|
||
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
DisplayName *string `json:"displayName"`
|
||
Email *string `json:"email"`
|
||
Role *string `json:"role"`
|
||
IsActive *bool `json:"isActive"`
|
||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben")
|
||
return
|
||
}
|
||
|
||
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, u)
|
||
}
|
||
|
||
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||
callerID := auth.GetUserID(r.Context())
|
||
caller, _ := s.store.GetUserByID(callerID)
|
||
targetUserID := chi.URLParam(r, "id")
|
||
targetUser, _ := s.store.GetUserByID(targetUserID)
|
||
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
|
||
writeError(w, http.StatusForbidden, "Keine Berechtigung")
|
||
return
|
||
}
|
||
|
||
var req struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Password) == "" {
|
||
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
|
||
return
|
||
}
|
||
hash, err := auth.HashPassword(req.Password)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||
return
|
||
}
|
||
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||
}
|
||
|
||
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||
targetUserID := chi.URLParam(r, "id")
|
||
currUserID := auth.GetUserID(r.Context())
|
||
if targetUserID == currUserID {
|
||
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
|
||
return
|
||
}
|
||
caller, _ := s.store.GetUserByID(currUserID)
|
||
targetUser, _ := s.store.GetUserByID(targetUserID)
|
||
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
|
||
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins")
|
||
return
|
||
}
|
||
if err := s.store.DeleteUser(targetUserID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
// --- INVITES MANAGEMENT ---
|
||
|
||
func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) {
|
||
invites, err := s.store.GetUserInvites()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, invites)
|
||
}
|
||
|
||
func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||
callerID := auth.GetUserID(r.Context())
|
||
caller, _ := s.store.GetUserByID(callerID)
|
||
|
||
var req struct {
|
||
Email string `json:"email"`
|
||
Role string `json:"role"`
|
||
TeamID string `json:"teamId"`
|
||
ValidDays int `json:"validDays"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||
req.Role = "member"
|
||
}
|
||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.TeamID, req.ValidDays)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, inv)
|
||
}
|
||
|
||
func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||
inviteID := chi.URLParam(r, "id")
|
||
if err := s.store.DeleteUserInvite(inviteID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||
}
|
||
|
||
// --- SERVER SETTINGS & SMTP ---
|
||
|
||
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
||
settings, err := s.store.GetSystemSettings()
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||
writeJSON(w, http.StatusOK, settings)
|
||
}
|
||
|
||
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||
var settings domain.SystemSettings
|
||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||
return
|
||
}
|
||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||
writeJSON(w, http.StatusOK, settings)
|
||
}
|
||
|
||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||
userID := auth.GetUserID(r.Context())
|
||
user, _ := s.store.GetUserByID(userID)
|
||
|
||
var req struct {
|
||
ToEmail string `json:"toEmail"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||
toEmail := strings.TrimSpace(req.ToEmail)
|
||
if toEmail == "" && user != nil {
|
||
toEmail = user.Email
|
||
}
|
||
if toEmail == "" {
|
||
writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich")
|
||
return
|
||
}
|
||
|
||
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
|
||
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"sent": true,
|
||
"toEmail": toEmail,
|
||
})
|
||
}
|
||
|
||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||
|
||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||
sys, _ := s.store.GetSystemSettings()
|
||
host := ""
|
||
port := "587"
|
||
user := ""
|
||
pass := ""
|
||
from := ""
|
||
useTLS := true
|
||
|
||
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
|
||
host = strings.TrimSpace(sys.SMTPHost)
|
||
port = strings.TrimSpace(sys.SMTPPort)
|
||
user = strings.TrimSpace(sys.SMTPUser)
|
||
pass = sys.SMTPPass
|
||
from = strings.TrimSpace(sys.SMTPFrom)
|
||
useTLS = sys.SMTPTLS
|
||
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
|
||
host = strings.TrimSpace(s.cfg.SMTPHost)
|
||
port = strings.TrimSpace(s.cfg.SMTPPort)
|
||
user = strings.TrimSpace(s.cfg.SMTPUser)
|
||
pass = s.cfg.SMTPPass
|
||
from = strings.TrimSpace(s.cfg.SMTPFrom)
|
||
useTLS = s.cfg.SMTPTLS
|
||
}
|
||
|
||
if host == "" {
|
||
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
|
||
}
|
||
if port == "" {
|
||
port = "587"
|
||
}
|
||
if from == "" {
|
||
if user != "" && strings.Contains(user, "@") {
|
||
from = user
|
||
} else {
|
||
from = "noreply@mindsift.local"
|
||
}
|
||
}
|
||
|
||
addr := fmt.Sprintf("%s:%s", host, port)
|
||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
|
||
|
||
var smtpAuth smtp.Auth
|
||
if user != "" || pass != "" {
|
||
smtpAuth = smtp.PlainAuth("", user, pass, host)
|
||
}
|
||
|
||
if port == "465" && useTLS {
|
||
tlsCfg := &tls.Config{ServerName: host}
|
||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer conn.Close()
|
||
client, err := smtp.NewClient(conn, host)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer client.Quit()
|
||
if smtpAuth != nil {
|
||
if err := client.Auth(smtpAuth); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := client.Mail(from); err != nil {
|
||
return err
|
||
}
|
||
if err := client.Rcpt(toEmail); err != nil {
|
||
return err
|
||
}
|
||
wc, err := client.Data()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = wc.Write([]byte(msg))
|
||
_ = wc.Close()
|
||
return err
|
||
}
|
||
|
||
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
|
||
}
|
||
|
||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||
payload := map[string]interface{}{
|
||
"content": "⏰ **MindSift Aufgaben-Alarm**",
|
||
"embeds": []map[string]interface{}{
|
||
{
|
||
"title": title,
|
||
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
|
||
"color": 2450411,
|
||
"footer": map[string]string{
|
||
"text": "MindSift Alarm-System",
|
||
},
|
||
},
|
||
},
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||
payload := map[string]interface{}{
|
||
"event": event,
|
||
"title": title,
|
||
"notes": notes,
|
||
"dueDate": dueDate,
|
||
"priority": priority,
|
||
"timestamp": domain.NowISO(),
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) startAlarmTicker() {
|
||
ticker := time.NewTicker(60 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
for range ticker.C {
|
||
alarms, err := s.store.GetDueTasksForAlarm()
|
||
if err != nil || len(alarms) == 0 {
|
||
continue
|
||
}
|
||
|
||
today := time.Now().Format("2006-01-02")
|
||
|
||
for _, a := range alarms {
|
||
if a.DueDate > today {
|
||
continue
|
||
}
|
||
if len(a.AlarmChannels) == 0 {
|
||
continue
|
||
}
|
||
|
||
sentAny := false
|
||
desc := a.Notes
|
||
if desc == "" {
|
||
desc = "Aufgabe ist jetzt fällig."
|
||
}
|
||
|
||
for _, ch := range a.AlarmChannels {
|
||
switch ch {
|
||
case "discord":
|
||
if a.DiscordWebhookURL != "" {
|
||
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
case "webhook":
|
||
if a.CustomWebhookURL != "" {
|
||
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
case "email":
|
||
if a.AlarmEmail != "" {
|
||
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, a.DueDate, a.Priority, desc)
|
||
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
|
||
sentAny = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if sentAny {
|
||
_ = s.store.MarkTaskAlarmSent(a.TaskID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|