refactor: split sqlite.go and server.go by domain (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s

Pure moves, no behaviour change: the storage layer now has users,
apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and
system files next to sqlite.go (store, schema, migrations); the API has
handlers_*.go per area plus realtime.go and alarms.go next to server.go
(server, router, middleware). Declarations were moved verbatim with an
AST-based tool; imports pruned with goimports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:14:33 +01:00
1 parent 7bba174454
commit 74c394efaa
26 files changed
+7702 -7467

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
cfg, err := s.smtpSettings()
if err != nil {
return err
}
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil {
step := ""
if se, ok := err.(*smtpStepError); ok {
step = se.Step
}
slog.Warn("E-Mail-Versand fehlgeschlagen", "smtp_step", step, "host", cfg.Host, "port", cfg.Port, "to", toEmail, "error", err)
return err
}
return nil
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
return err
}
httpClient := guardedClient(allowPrivate)
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 {
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
return err
}
httpClient := guardedClient(allowPrivate)
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
}
// alarmDue reports whether the alarm for a due date should go out now: from the due time (09:00 for
// all-day tasks) minus the user's lead time on. Unreadable dates never fire.
func alarmDue(dueDate string, notifyBeforeMinutes int, now time.Time) bool {
at, ok := domain.DueAlarmAt(dueDate, notifyBeforeMinutes, now.Location())
return ok && !now.Before(at)
}
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
}
now := time.Now()
for _, a := range alarms {
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
continue
}
if len(a.AlarmChannels) == 0 {
continue
}
sentAny := false
due := domain.HumanDue(a.DueDate)
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, due, 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, due, 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)
}
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- CUSTOM THEMES ---
func (s *Server) handleGetThemes(w http.ResponseWriter, r *http.Request) {
themes, err := s.store.GetUserThemes(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, themes)
}
// handleSaveTheme creates a theme, or updates it when an id of the caller's theme is given.
func (s *Server) handleSaveTheme(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
}
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxThemeCSSBytes+16*1024)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage oder Theme zu groß")
return
}
theme, err := s.store.SaveUserTheme(auth.GetUserID(r.Context()), strings.TrimSpace(req.ID), req.Name, req.CSS)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
status := http.StatusCreated
if req.ID != "" {
status = http.StatusOK
}
writeJSON(w, status, theme)
}
func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteUserTheme(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// confirmPassword checks the current password sent as {"password": "…"} before destructive actions.
func (s *Server) confirmPassword(w http.ResponseWriter, r *http.Request) (*domain.User, bool) {
var req struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
user, err := s.store.GetUserByID(auth.GetUserID(r.Context()))
if err != nil || user == nil {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return nil, false
}
if req.Password == "" || !auth.CheckPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusForbidden, "Passwort ist falsch")
return nil, false
}
return user, true
}
// runImport answers a preview (?preview=1) with what would be created, otherwise imports.
func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage.ImportPlan, err error) {
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if r.URL.Query().Get("preview") == "1" {
writeJSON(w, http.StatusOK, plan.Summary(false))
return
}
userID := auth.GetUserID(r.Context())
// mode=restore (MindSift data only): replace everything instead of adding next to it. The plan is
// fully read and validated at this point, so a broken file never wipes anything.
if r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift") {
if err := s.store.PurgeUserData(userID); err != nil {
writeError(w, http.StatusInternalServerError, "Wiederherstellen abgebrochen: "+err.Error())
return
}
}
sum, err := s.store.ApplyImport(userID, plan)
if err != nil {
slog.Error("Import abgebrochen", "user_id", userID, "error", err)
writeError(w, http.StatusInternalServerError, "Import abgebrochen: "+err.Error())
return
}
writeJSON(w, http.StatusOK, sum)
}
// handleImportMindSift restores MindSift's own data: the JSON backup or the Markdown archive (ZIP).
func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) {
data, name, err := importUpload(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
plan, err := storage.PlanMindSiftImport(data, name)
s.runImport(w, r, plan, err)
}
// handleImportObsidian imports an Obsidian vault (ZIP) into a folder named after the vault.
func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
data, name, err := importUpload(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
plan, err := storage.PlanObsidianImport(bytes.NewReader(data), int64(len(data)), name)
s.runImport(w, r, plan, err)
}
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
// Headers are already sent; the client gets a truncated archive
slog.Error("Markdown-Export fehlgeschlagen", "user_id", userID, "error", err)
}
}
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
}
// ?format=zip: the same JSON compressed (backups grow quickly with notes); plain JSON stays the API default
if r.URL.Query().Get("format") == "zip" {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02")))
if err := storage.WriteBackupZip(exportData, w); err != nil {
slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err)
}
return
}
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
writeJSON(w, http.StatusOK, exportData)
}
+340
View File
@@ -0,0 +1,340 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- ORGANIZATIONS & TEAMS ---
func (s *Server) handleAdminGetOrganizations(w http.ResponseWriter, r *http.Request) {
orgs, err := s.store.GetOrganizations()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, orgs)
}
func (s *Server) handleAdminCreateOrganization(w http.ResponseWriter, r *http.Request) {
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(req.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, org)
}
func (s *Server) handleAdminRenameOrganization(w http.ResponseWriter, r *http.Request) {
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
}
if err := s.store.RenameOrganization(chi.URLParam(r, "id"), req.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (s *Server) handleAdminDeleteOrganization(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteOrganization(chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleAdminSetUserOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
OrganizationID string `json:"organizationId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.SetUserOrganization(chi.URLParam(r, "id"), req.OrganizationID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
// handleAdminSharingImpact counts the shares and group memberships a switch to ?mode= would remove.
func (s *Server) handleAdminSharingImpact(w http.ResponseWriter, r *http.Request) {
n, err := s.store.PruneSharing(r.URL.Query().Get("mode"), true)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"affected": n})
}
// --- 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"`
OrganizationID string `json:"organizationId"`
}
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
}
if req.OrganizationID != "" {
if err := s.store.SetUserOrganization(u.ID, req.OrganizationID); err == nil {
u.OrganizationID = req.OrganizationID
}
}
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
}
if req.DiscordWebhookURL != nil {
if err := validateWebhookURL(*req.DiscordWebhookURL, true, s.allowPrivateWebhooks()); err != nil {
writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error())
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"`
OrganizationID string `json:"organizationId"`
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.OrganizationID, 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
}
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// A stricter mode takes away what is no longer allowed (shares, group memberships, assignments)
if settings.SharingMode != previousMode {
_, _ = s.store.PruneSharing(settings.SharingMode, false)
}
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,
})
}
+523
View File
@@ -0,0 +1,523 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- 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,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
// Public so the login screen can link Impressum and Datenschutz
"legal": map[string]interface{}{
"imprintUrl": s.cfg.Legal.ImprintURL,
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
"privacyUrl": s.cfg.Legal.PrivacyURL,
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
},
})
}
// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login).
func (s *Server) handleLegalText(w http.ResponseWriter, r *http.Request) {
content := ""
switch chi.URLParam(r, "type") {
case "imprint":
content = s.cfg.Legal.ImprintContent
case "privacy":
content = s.cfg.Legal.PrivacyContent
}
if content == "" {
writeError(w, http.StatusNotFound, "Kein Text hinterlegt")
return
}
writeJSON(w, http.StatusOK, map[string]string{"content": content})
}
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"`
CreateSampleData bool `json:"createSampleData"`
}
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
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
_ = s.store.UpdateSystemSettings(sys)
// In organization mode the first admin starts in an organization named after the instance
if sys.SharingMode == storage.SharingOrganizations {
if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil {
_ = s.store.SetUserOrganization(user.ID, org.ID)
user.OrganizationID = org.ID
}
}
}
if req.CreateSampleData {
_ = 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.OrganizationID != "" {
_ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID)
user.OrganizationID = validInvite.OrganizationID
}
}
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,
// The settings disable "Konto löschen" for the last administrator
"isLastAdmin": s.store.IsLastAdmin(userID),
})
}
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)
}
// handlePurgeOwnData deletes all notes, tasks, thoughts and lists of the caller; the account stays.
func (s *Server) handlePurgeOwnData(w http.ResponseWriter, r *http.Request) {
user, ok := s.confirmPassword(w, r)
if !ok {
return
}
if err := s.store.PurgeUserData(user.ID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
slog.Info("Benutzer hat alle eigenen Daten gelöscht", "user_id", user.ID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleDeleteOwnAccount deletes the caller's account with all data; never the last administrator.
func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) {
user, ok := s.confirmPassword(w, r)
if !ok {
return
}
if err := s.store.DeleteOwnAccount(user.ID); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrLastAdmin) {
status = http.StatusForbidden
}
writeError(w, status, err.Error())
return
}
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
return nil, "", fmt.Errorf("Keine Datei erhalten oder Datei zu groß (max. 100 MB)")
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, storage.MaxImportUploadBytes+1))
if err != nil || len(data) > storage.MaxImportUploadBytes {
return nil, "", fmt.Errorf("Datei zu groß (max. 100 MB)")
}
return data, header.Filename, nil
}
// --- 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
}
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
allowPrivate := s.allowPrivateWebhooks()
if req.DiscordWebhookURL != nil {
if err := validateWebhookURL(*req.DiscordWebhookURL, true, allowPrivate); err != nil {
writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error())
return
}
}
if req.CustomWebhookURL != nil {
if err := validateWebhookURL(*req.CustomWebhookURL, false, allowPrivate); err != nil {
writeError(w, http.StatusBadRequest, "Webhook-URL: "+err.Error())
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)
}
+315
View File
@@ -0,0 +1,315 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- 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) handleGetBacklinks(w http.ResponseWriter, r *http.Request) {
links, err := s.store.GetBacklinks(auth.GetUserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
return
}
writeJSON(w, http.StatusOK, links)
}
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"`
IsPinned *bool `json:"isPinned"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Blocks != nil {
writeError(w, http.StatusBadRequest, "Blöcke werden über POST /api/v1/docs/{id}/sync gespeichert")
return
}
doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Title != nil {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
}
if req.Title != nil || req.Icon != nil || req.FolderPath != nil {
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
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) handleGetTrash(w http.ResponseWriter, r *http.Request) {
items, err := s.store.GetTrash(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
if err := s.store.RestoreDocument(userID, docID); err != nil {
writeTrashError(w, err)
return
}
// Everyone with access reloads their tree, so the note is back for shared users as well
authorized, _ := s.store.GetAuthorizedDocUsers(docID)
for _, uid := range append(authorized, userID) {
s.wsHub.BroadcastToUser(uid, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"restored": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"restored": true})
}
func (s *Server) handlePurgeDocument(w http.ResponseWriter, r *http.Request) {
if err := s.store.PurgeDocument(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeTrashError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
ids, err := s.store.EmptyTrash(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int{"deleted": len(ids)})
}
func writeTrashError(w http.ResponseWriter, err error) {
if errors.Is(err, storage.ErrNotInTrash) {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
}
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
}
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
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")
docUsers, _ := s.store.GetAuthorizedDocUsers(docID)
s.wsHub.BroadcastToUsers(append(docUsers, userID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"docId": docID})
writeJSON(w, http.StatusOK, doc)
}
// --- 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)
}
+295
View File
@@ -0,0 +1,295 @@
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
// --- 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.BroadcastToUser(userID, "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.BroadcastToUser(userID, "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.BroadcastToUser(userID, "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.BroadcastToUser(userID, "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")
audience := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(append(audience, userID), "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.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
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)
audienceBefore := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
audienceAfter := s.store.GetSharedFolderUserIDs(folderID)
stillAllowed := make(map[string]bool, len(audienceAfter))
for _, uid := range audienceAfter {
stillAllowed[uid] = true
}
s.wsHub.BroadcastToUsers(append(audienceBefore, audienceAfter...), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
for _, docID := range docIDs {
// Users who lost access remove the notes; the rest refresh them
for _, uid := range audienceBefore {
if !stillAllowed[uid] {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- 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,
})
}
+144
View File
@@ -0,0 +1,144 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
// --- 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 ")
}
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
user, _ := s.authenticateToken(token)
if 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.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"status": "ingested",
"thought": thought,
})
}
+266
View File
@@ -0,0 +1,266 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- 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) {
userID := auth.GetUserID(r.Context())
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, userID)
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})
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- 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"`
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
}
audienceBefore := s.store.GetTaskListUserIDs(listID)
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID, req.Columns)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
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")
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.DeleteTaskList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_DELETED")
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
}
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
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
}
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// broadcastTaskListChange notifies everyone who could see the list before or can see it now. Only the ID is sent,
// because the list payload depends on the receiver's permission; clients reload their lists.
func (s *Server) broadcastTaskListChange(listID string, audienceBefore []string, event string) {
users := append(audienceBefore, s.store.GetTaskListUserIDs(listID)...)
s.wsHub.BroadcastToUsers(users, event, map[string]string{"id": listID})
}
+201
View File
@@ -0,0 +1,201 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
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"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if s.store.SharingMode() == storage.SharingPrivate {
writeError(w, http.StatusForbidden, storage.ErrSharingDisabled.Error())
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für die Gruppe ist erforderlich")
return
}
// A group belongs to its creator's organization; members can only come from there
creator, _ := s.store.GetUserByID(userID)
orgID := ""
if creator != nil {
orgID = creator.OrganizationID
}
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) != "" {
if u, err := s.store.GetUserByEmail(req.Email); err == nil && u != nil {
targetUserID = u.ID
}
}
if targetUserID == "" && strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
return
}
// Only people the group admin can see; unknown and invisible look the same
if targetUserID == "" || !s.store.CanShareWith(userID, "user", targetUserID) {
writeError(w, http.StatusNotFound, storage.ErrTargetNotFound.Error())
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})
}
+326
View File
@@ -0,0 +1,326 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
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.BroadcastToUsers(s.taskAudience(thought), "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.BroadcastToUsers(s.taskAudience(updated), "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
}
// ReorderTasks updates by ID only, so every item must be a task the caller may edit
audience := []string{userID}
for _, it := range req.Items {
th, err := s.store.GetThoughtByID(userID, it.ID)
if err != nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
if th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
audience = append(audience, s.taskAudience(th)...)
}
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(audience, "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"`
Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
before, _ := s.store.GetThoughtByID(userID, thoughtID)
// The rule is set first, so completing a task in the same request already repeats it
if req.Recurrence != nil {
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
writeStoreError(w, http.StatusBadRequest, err)
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
}
if req.Assignees != nil {
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
updated.Assignees = assignees
}
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
}
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "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")
th, err := s.store.GetThoughtByID(userID, thoughtID)
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
audience := s.taskAudience(th)
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeStoreError(w, http.StatusInternalServerError, err)
return
}
s.wsHub.BroadcastToUsers(audience, "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})
}
// --- SEARCH & EXPORT ---
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
q := r.URL.Query().Get("q")
res, err := s.store.SearchGlobal(userID, q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
+128
View File
@@ -0,0 +1,128 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
)
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 ""
}
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 == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
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 == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
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) {
userID := auth.GetUserID(r.Context())
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, userID, req.Room)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
return
}
if !s.wsHub.JoinSSERoomByID(req.SSEID, userID, req.Room) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Raum")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
File diff suppressed because it is too large. Load diff