872 lines
26 KiB
Go
872 lines
26 KiB
Go
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)
|
||
}
|