Files
MindSift/internal/api/server.go
T
sandrodach 84bc6884b0
MindSift Multi-Arch Docker Build / Lint, Build & Unit Tests (push) Successful in 2m7s
MindSift Multi-Arch Docker Build / Build & Push Docker Image (push) Failing after 29s
First draft
2026-10-24 16:56:44 +02:00

872 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strings"
"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 {
return &Server{
cfg: cfg,
store: store,
wsHub: wsHub,
staticFS: staticFS,
}
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// Public Health & Webhook Routes
r.Get("/api/v1/health", s.handleHealth)
r.Post("/api/v1/auth/register", s.handleRegister)
r.Post("/api/v1/auth/login", s.handleLogin)
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
// Authenticated API Routes
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Get("/api/v1/auth/me", s.handleMe)
r.Get("/api/v1/stats", s.handleStats)
r.Get("/api/v1/search", s.handleSearch)
r.Get("/api/v1/export", s.handleExport)
// Instant Capture & Thoughts
r.Post("/api/v1/inbox", s.handleCaptureInbox)
r.Get("/api/v1/thoughts", s.handleGetThoughts)
r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought)
r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought)
r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded)
// Tinder-Style Triage & Tasks
r.Get("/api/v1/triage/deck", s.handleGetTriageDeck)
r.Patch("/api/v1/triage/{id}", s.handleTriageAction)
r.Get("/api/v1/tasks", s.handleGetTasks)
// Workspace Documents
r.Get("/api/v1/documents", s.handleGetDocuments)
r.Post("/api/v1/documents", s.handleCreateDocument)
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
// Shared Spaces & Realtime Lists
r.Get("/api/v1/spaces", s.handleGetSpaces)
r.Post("/api/v1/spaces", s.handleCreateSpace)
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList)
r.Get("/api/v1/lists", s.handleGetAllLists)
r.Delete("/api/v1/lists/{id}", s.handleDeleteList)
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
// 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)
})
// 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 {
// SPA client-side routing fallback to index.html
req.URL.Path = "/"
}
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.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Check Authorization Bearer Token
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if strings.HasPrefix(tokenStr, "mindsift_sec_") {
user, err := s.store.ValidateAPIKeyAndGetUser(tokenStr)
if err == nil && user != nil {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
}
}
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
if err == nil && claims != nil {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), claims.UserID)))
return
}
}
// 2. Check X-API-Key Header
apiKey := r.Header.Get("X-API-Key")
if apiKey != "" {
user, err := s.store.ValidateAPIKeyAndGetUser(apiKey)
if err == nil && user != nil {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
}
}
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte einloggen")
})
}
// --- HANDLERS ---
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"service": "mindsift",
"time": domain.NowISO(),
})
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if !s.cfg.AllowRegistration {
count, _ := s.store.CountUsers()
if count > 0 {
writeError(w, http.StatusForbidden, "Registrierung ist auf dieser Instanz deaktiviert")
return
}
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
return
}
if strings.TrimSpace(req.DisplayName) == "" {
req.DisplayName = strings.Split(req.Email, "@")[0]
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
return
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName)
if err != nil {
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
return
}
_ = s.store.SeedStarterContent(user)
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
user, err := s.store.GetUserByEmail(req.Email)
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusUnauthorized, "E-Mail oder Passwort ungültig")
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
user, err := s.store.EnsureDefaultUser()
if err != nil {
writeError(w, http.StatusInternalServerError, "Standard-Profil konnte nicht geladen werden")
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token-Fehler")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
if err != nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
stats, _ := s.store.GetDashboardStats(userID)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user": user,
"stats": stats,
})
}
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
stats, err := s.store.GetDashboardStats(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, stats)
}
func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Content string `json:"content"`
Priority string `json:"priority"`
Source string `json:"source"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"` // Compatibility with snake_case README example
}
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
}
thought, err := s.store.CreateThought(userID, req.Content, req.Priority, req.Source, dueDate)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, thought)
}
func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
status := r.URL.Query().Get("status")
thoughts, err := s.store.GetThoughts(userID, status, 250)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Action string `json:"action"` // keep | discard | restore
ConvertTo string `json:"convertTo"`
ConvertTo2 string `json:"convert_to"`
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
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
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// If converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst)
}
}
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tasks, err := s.store.GetTasks(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Content *string `json:"content"`
IsCompleted *bool `json:"isCompleted"`
DueDate *string `json:"dueDate"`
Priority *string `json:"priority"`
}
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)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
count, err := s.store.PurgeDiscardedThoughts(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"purged": count})
}
// --- DOCUMENTS ---
func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docs, err := s.store.GetDocuments(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, docs)
}
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SpaceID string `json:"spaceId"`
Title string `json:"title"`
Icon string `json:"icon"`
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.Blocks, req.Tags, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
doc, err := s.store.GetDocumentByID(userID, docID)
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
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.Blocks, req.Tags, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- SPACES & REALTIME LISTS ---
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
spaces, err := s.store.GetSpacesForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, spaces)
}
func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, sp)
}
func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
InviteCode string `json:"inviteCode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, sp)
}
func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id")
lists, err := s.store.GetListsForSpace(spaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
lists, err := s.store.GetAllListsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id")
var req struct {
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst)
writeJSON(w, http.StatusCreated, lst)
}
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
lst, _ := s.store.GetListByID(listID)
if err := s.store.DeleteList(listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
var req struct {
Content string `json:"content"`
Category string `json:"category"`
Quantity string `json:"quantity"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item)
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
var req struct {
IsCompleted bool `json:"isCompleted"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
user, _ := s.store.GetUserByID(userID)
userName := "Familie"
if user != nil {
userName = user.DisplayName
}
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item)
}
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
_, err := s.store.DeleteListItem(itemID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{
"itemId": itemID,
"listId": listID,
})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
removed, err := s.store.ClearCompletedListItems(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if lst, err := s.store.GetListByID(listID); err == nil && lst != nil {
s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst)
}
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "spaceId")
token := r.URL.Query().Get("token")
userID := "anonymous"
if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
userID = claims.UserID
}
}
s.wsHub.ServeWS(w, r, spaceID, userID)
}
// --- API KEYS & WEBHOOK INGESTION ---
func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keys, err := s.store.GetAPIKeys(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, keys)
}
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
key, err := s.store.CreateAPIKey(userID, req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, key)
}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keyID := chi.URLParam(r, "id")
if err := s.store.DeleteAPIKey(userID, keyID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
token = r.Header.Get("X-API-Key")
}
if token == "" && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
var user *domain.User
var err error
if strings.HasPrefix(token, "mindsift_sec_") {
user, err = s.store.ValidateAPIKeyAndGetUser(token)
} else if token != "" {
if claims, jwtErr := auth.ValidateToken(s.cfg.JWTSecret, token); jwtErr == nil {
user, err = s.store.GetUserByID(claims.UserID)
}
}
if err != nil || user == nil {
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Priority string `json:"priority"`
Tags []string `json:"tags"`
DueDate string `json:"dueDate"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON-Payload")
return
}
bodyText := strings.TrimSpace(req.Title)
if req.Content != "" {
if bodyText != "" {
bodyText = bodyText + " — " + strings.TrimSpace(req.Content)
} else {
bodyText = strings.TrimSpace(req.Content)
}
}
if bodyText == "" {
writeError(w, http.StatusBadRequest, "Feld 'title' oder 'content' ist erforderlich")
return
}
for _, tag := range req.Tags {
cleanTag := strings.TrimPrefix(strings.TrimSpace(tag), "#")
if cleanTag != "" && !strings.Contains(strings.ToLower(bodyText), "#"+strings.ToLower(cleanTag)) {
bodyText += fmt.Sprintf(" #%s", cleanTag)
}
}
if req.Priority == "" {
req.Priority = "high"
}
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, "webhook", req.DueDate)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"status": "ingested",
"thought": thought,
})
}
// --- SEARCH & EXPORT ---
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
q := r.URL.Query().Get("q")
thoughts, docs, err := s.store.SearchAll(userID, q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"thoughts": thoughts,
"documents": docs,
})
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
exportData, err := s.store.ExportUserData(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
writeJSON(w, http.StatusOK, exportData)
}