From 74c394efaa20f4df56c7ec00f36b19bfb15484bc Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 08:14:33 +0100 Subject: [PATCH] refactor: split sqlite.go and server.go by domain (FLAW-20) 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 --- Doku/architecture.md | 4 +- Doku/flaws.md | 3 +- internal/api/alarms.go | 157 + internal/api/handlers_account.go | 158 + internal/api/handlers_admin.go | 340 ++ internal/api/handlers_auth.go | 523 +++ internal/api/handlers_documents.go | 315 ++ internal/api/handlers_folders.go | 295 ++ internal/api/handlers_integrations.go | 144 + internal/api/handlers_lists.go | 266 ++ internal/api/handlers_tasklists.go | 158 + internal/api/handlers_teams.go | 201 ++ internal/api/handlers_thoughts.go | 326 ++ internal/api/realtime.go | 128 + internal/api/server.go | 2871 ---------------- internal/storage/apikeys.go | 75 + internal/storage/documents.go | 958 ++++++ internal/storage/folders.go | 862 +++++ internal/storage/lists.go | 502 +++ internal/storage/sqlite.go | 4593 ------------------------- internal/storage/system.go | 208 ++ internal/storage/tags.go | 242 ++ internal/storage/tasklists.go | 541 +++ internal/storage/teams.go | 247 ++ internal/storage/thoughts.go | 639 ++++ internal/storage/users.go | 413 +++ 26 files changed, 7702 insertions(+), 7467 deletions(-) create mode 100644 internal/api/alarms.go create mode 100644 internal/api/handlers_account.go create mode 100644 internal/api/handlers_admin.go create mode 100644 internal/api/handlers_auth.go create mode 100644 internal/api/handlers_documents.go create mode 100644 internal/api/handlers_folders.go create mode 100644 internal/api/handlers_integrations.go create mode 100644 internal/api/handlers_lists.go create mode 100644 internal/api/handlers_tasklists.go create mode 100644 internal/api/handlers_teams.go create mode 100644 internal/api/handlers_thoughts.go create mode 100644 internal/api/realtime.go create mode 100644 internal/storage/apikeys.go create mode 100644 internal/storage/documents.go create mode 100644 internal/storage/folders.go create mode 100644 internal/storage/lists.go create mode 100644 internal/storage/system.go create mode 100644 internal/storage/tags.go create mode 100644 internal/storage/tasklists.go create mode 100644 internal/storage/teams.go create mode 100644 internal/storage/thoughts.go create mode 100644 internal/storage/users.go diff --git a/Doku/architecture.md b/Doku/architecture.md index b949ec6..fcb0d1d 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -30,8 +30,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | Pfad | Inhalt | |---|---| | `cmd/server/` | `main.go`, eingebettetes `dist/` | -| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) | -| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) | +| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` | +| `internal/storage/` | `sqlite.go` (Store, Schema, Migrationen); Datenlogik nach Bereich: `users.go`, `apikeys.go`, `thoughts.go` (inkl. Alarme), `tasklists.go`, `documents.go`, `folders.go`, `tags.go`, `lists.go`, `teams.go`, `system.go` (Einstellungen, Statistik, Export), dazu `visibility.go`, `docsync.go`, `search.go`, `trash.go`, `backlinks.go`, `recurrence.go` u. a. | | `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion | | `internal/ws/` | `hub.go` | | `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung | diff --git a/Doku/flaws.md b/Doku/flaws.md index 2e859d6..effe319 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -29,7 +29,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** | | FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** | | FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig | -| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig | +| FLAW-20 | Qualität | ~~Sehr große Dateien~~; duplizierte Zugriffslogik | Niedrig | | FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig | | FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig | | FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig | @@ -161,6 +161,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript. ### FLAW-20: Dateigröße und Duplikate +- **Teilweise behoben:** `sqlite.go` und `server.go` sind nach Bereichen in eigene Dateien aufgeteilt (reine Verschiebung, Verhalten unverändert; siehe [architecture.md](architecture.md#verzeichnisse)). Offen: große Frontend-Dateien und die doppelte Zugriffsabfrage. - `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500). - Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen. - **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren. diff --git a/internal/api/alarms.go b/internal/api/alarms.go new file mode 100644 index 0000000..454d87c --- /dev/null +++ b/internal/api/alarms.go @@ -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) + } + } + } +} diff --git a/internal/api/handlers_account.go b/internal/api/handlers_account.go new file mode 100644 index 0000000..7e25633 --- /dev/null +++ b/internal/api/handlers_account.go @@ -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) +} diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go new file mode 100644 index 0000000..c37a8a6 --- /dev/null +++ b/internal/api/handlers_admin.go @@ -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, + }) +} diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go new file mode 100644 index 0000000..a3e0ca0 --- /dev/null +++ b/internal/api/handlers_auth.go @@ -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) +} diff --git a/internal/api/handlers_documents.go b/internal/api/handlers_documents.go new file mode 100644 index 0000000..aacfcca --- /dev/null +++ b/internal/api/handlers_documents.go @@ -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) +} diff --git a/internal/api/handlers_folders.go b/internal/api/handlers_folders.go new file mode 100644 index 0000000..39d2cd2 --- /dev/null +++ b/internal/api/handlers_folders.go @@ -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, + }) +} diff --git a/internal/api/handlers_integrations.go b/internal/api/handlers_integrations.go new file mode 100644 index 0000000..487ebbe --- /dev/null +++ b/internal/api/handlers_integrations.go @@ -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, + }) +} diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go new file mode 100644 index 0000000..300e010 --- /dev/null +++ b/internal/api/handlers_lists.go @@ -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}) +} diff --git a/internal/api/handlers_tasklists.go b/internal/api/handlers_tasklists.go new file mode 100644 index 0000000..8df81d8 --- /dev/null +++ b/internal/api/handlers_tasklists.go @@ -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}) +} diff --git a/internal/api/handlers_teams.go b/internal/api/handlers_teams.go new file mode 100644 index 0000000..f4885e4 --- /dev/null +++ b/internal/api/handlers_teams.go @@ -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}) +} diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go new file mode 100644 index 0000000..27cfbb2 --- /dev/null +++ b/internal/api/handlers_thoughts.go @@ -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) +} diff --git a/internal/api/realtime.go b/internal/api/realtime.go new file mode 100644 index 0000000..03d17d1 --- /dev/null +++ b/internal/api/realtime.go @@ -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}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 658b871..38cd352 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,11 +1,8 @@ package api import ( - "bytes" "encoding/json" "errors" - "fmt" - "io" "io/fs" "log/slog" "net/http" @@ -461,2871 +458,3 @@ func (s *Server) adminMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } - -// --- HANDLERS --- - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]interface{}{ - "status": "ok", - "service": "mindsift", - "time": domain.NowISO(), - }) -} - -func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { - count, _ := s.store.CountUsers() - sys, _ := s.store.GetSystemSettings() - allowReg := false - instanceName := "MindSift" - if sys != nil { - allowReg = sys.AllowRegistration - if sys.InstanceName != "" { - instanceName = sys.InstanceName - } - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "needsSetup": count == 0, - "allowRegistration": allowReg, - "instanceName": instanceName, - "demoMode": s.cfg.DemoMode, - "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) -} - -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}) -} - -// --- 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) -} - -// --- 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}) -} - -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) -} - -// --- WORKSPACE TAGS & PAGES --- - -func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - tags, err := s.store.GetWorkspaceTags(userID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, tags) -} - -func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - tag := chi.URLParam(r, "tag") - details, err := s.store.GetTagDetails(userID, tag) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, details) -} - -func (s *Server) handleUpdateTagPageNote(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - tag := chi.URLParam(r, "tag") - var req struct { - Content string `json:"content"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - updatedAt, err := s.store.UpdateTagPageNote(userID, tag, req.Content) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{ - "tag": tag, - "content": req.Content, - "updatedAt": updatedAt, - }) -} - -// --- DOCUMENT SHARING & COLLABORATION --- - -func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) { - docID := chi.URLParam(r, "id") - shares, err := s.store.GetDocShares(docID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, shares) -} - -func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - var req struct { - UserID string `json:"userId"` - Email string `json:"email"` - TeamID string `json:"teamId"` - Permission string `json:"permission"` // read | write - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - if req.TeamID != "" { - share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") - s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") - writeJSON(w, http.StatusCreated, share) - return - } - - target := req.UserID - if target == "" { - target = req.Email - } - if target == "" { - writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich") - return - } - - share, err := s.store.CreateDocShare(userID, docID, target, req.Permission) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") - s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") - writeJSON(w, http.StatusCreated, share) -} - -func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - targetUserID := chi.URLParam(r, "userId") - if err := s.store.DeleteDocShare(userID, docID, targetUserID); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - s.wsHub.BroadcastToUser(targetUserID, "DOC_DELETED", map[string]string{"id": docID}) - s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") - writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) -} - -func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - users, err := s.store.GetCollaborators(userID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, users) -} - -func (s *Server) authenticateUserFromReq(r *http.Request) string { - token := r.URL.Query().Get("token") - if token == "" { - if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { - token = strings.TrimPrefix(authHdr, "Bearer ") - } - } - if token != "" { - if strings.HasPrefix(token, "mindsift_sec_") { - if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive { - return user.ID - } - } - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil { - if !s.store.IsTokenRevoked(token) { - if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive { - if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion { - return claims.UserID - } - } - } - } - } - return "" -} - -// --- REALTIME LISTS & SHARING --- - -func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - lists, err := s.store.GetAllListsForUser(userID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, lists) -} - -func (s *Server) handleGetListByID(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - listID := chi.URLParam(r, "id") - lst, err := s.store.GetListByID(userID, listID) - if err != nil { - writeError(w, http.StatusNotFound, "Liste nicht gefunden") - return - } - writeJSON(w, http.StatusOK, lst) -} - -func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - var req struct { - Title string `json:"title"` - Icon string `json:"icon"` - ListType string `json:"listType"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - lst, err := s.store.CreateList(userID, req.Title, req.Icon, req.ListType) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst) - writeJSON(w, http.StatusCreated, lst) -} - -func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - listID := chi.URLParam(r, "id") - if err := s.store.DeleteList(userID, listID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID}) - writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) -} - -func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) { - listID := chi.URLParam(r, "id") - shares, err := s.store.GetListShares(listID) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, shares) -} - -func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) { - currentUserID := auth.GetUserID(r.Context()) - listID := chi.URLParam(r, "id") - - var req struct { - TargetType string `json:"targetType"` // "user" or "team" - TargetID string `json:"targetId"` // userId/email or teamId - Role string `json:"role"` // "editor" or "viewer" - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - targetType := strings.ToLower(strings.TrimSpace(req.TargetType)) - if targetType != "team" { - targetType = "user" - } - - role := strings.ToLower(strings.TrimSpace(req.Role)) - if role != "viewer" && role != "editor" { - role = "editor" - } - - share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - if targetType == "team" { - members, _ := s.store.GetTeamMembers(share.TargetID) - for _, tm := range members { - s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share) - } - } else { - s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share) - } - s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share) - - writeJSON(w, http.StatusCreated, share) -} - -func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) { - currentUserID := auth.GetUserID(r.Context()) - listID := chi.URLParam(r, "id") - shareID := chi.URLParam(r, "shareId") - - if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{ - "listId": listID, - "shareId": shareID, - }) - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { - 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}) -} - -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}) -} - -// --- 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, - }) -} - -// --- 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) -} - -// --- 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 -} - -// 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 -} - -// 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) -} - -// --- 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) -} - -// --- 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}) -} - -// --- 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}) -} - -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}) -} - -// --- 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, - }) -} - -// --- 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) - } - } - } -} diff --git a/internal/storage/apikeys.go b/internal/storage/apikeys.go new file mode 100644 index 0000000..80a2c84 --- /dev/null +++ b/internal/storage/apikeys.go @@ -0,0 +1,75 @@ +package storage + +import ( + "errors" + "strings" + + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/domain" +) + +// --- API KEYS & WEBHOOKS --- + +func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) { + if strings.TrimSpace(name) == "" { + name = "Smarthome / Webhook Key" + } + rawKey, prefix, hash := auth.GenerateAPIKey() + k := &domain.APIKey{ + ID: domain.NewID("key"), + UserID: userID, + Name: strings.TrimSpace(name), + KeyPrefix: prefix, + KeyHash: hash, + RawKey: rawKey, + Scopes: "ingest,read", + CreatedAt: domain.NowISO(), + } + + _, err := s.db.Exec( + `INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt, + ) + if err != nil { + return nil, err + } + return k, nil +} + +func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) { + rows, err := s.db.Query( + `SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at + FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + keys := make([]domain.APIKey, 0) + for rows.Next() { + var k domain.APIKey + if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil { + keys = append(keys, k) + } + } + return keys, nil +} + +func (s *Store) DeleteAPIKey(userID, keyID string) error { + _, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID) + return err +} + +func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) { + hash := auth.HashAPIKey(strings.TrimSpace(rawKey)) + var keyID, userID string + err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID) + if err != nil { + return nil, errors.New("invalid API key") + } + _, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID) + return s.GetUserByID(userID) +} diff --git a/internal/storage/documents.go b/internal/storage/documents.go new file mode 100644 index 0000000..4601b82 --- /dev/null +++ b/internal/storage/documents.go @@ -0,0 +1,958 @@ +package storage + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock { + if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 { + blocks := flatDocumentBlocks(docID, d) + if links := s.docTaskLinks(docID); len(links) > 0 { + for i := range blocks { + blocks[i].TaskID = links[blocks[i].ID] + } + } + return blocks + } + + // Very old notes only stored blocks_json; convert them once + blocks := make([]domain.DocumentBlock, 0) + if fallbackJSON != "" && fallbackJSON != "[]" { + _ = json.Unmarshal([]byte(fallbackJSON), &blocks) + if len(blocks) > 0 { + s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO()) + if d, _, err := loadCollabDoc(s.db, docID); err == nil { + return flatDocumentBlocks(docID, d) + } + } + } + return blocks +} + +// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID. +func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) { + userSet := make(map[string]bool) + + var ownerID, sharedFolderID string + err := s.db.QueryRow(`SELECT user_id, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&ownerID, &sharedFolderID) + if err != nil { + return nil, err + } + if ownerID != "" { + userSet[ownerID] = true + } + + // Direct user shares + uRows, err := s.db.Query(`SELECT user_id FROM doc_shares WHERE doc_id = ?`, docID) + if err == nil { + for uRows.Next() { + var uid string + if err := uRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = uRows.Close() + } + + // Team shares + tRows, err := s.db.Query(` + SELECT tm.user_id + FROM doc_team_shares dts + JOIN team_members tm ON tm.team_id = dts.team_id + WHERE dts.doc_id = ? + `, docID) + if err == nil { + for tRows.Next() { + var uid string + if err := tRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = tRows.Close() + } + + // Shared folder access + if sharedFolderID != "" { + sfUsers, err := s.GetAuthorizedSharedFolderUsers(sharedFolderID) + if err == nil { + for _, uid := range sfUsers { + userSet[uid] = true + } + } + } + + users := make([]string, 0, len(userSet)) + for uid := range userSet { + users = append(users, uid) + } + return users, nil +} + +// GetAuthorizedSharedFolderUsers returns all user IDs who have access to a shared folder. +func (s *Store) GetAuthorizedSharedFolderUsers(folderID string) ([]string, error) { + userSet := make(map[string]bool) + + var ownerID string + if err := s.db.QueryRow(`SELECT owner_id FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&ownerID); err == nil && ownerID != "" { + userSet[ownerID] = true + } + + rows, err := s.db.Query(` + SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? + `, folderID) + if err == nil { + type shareTarget struct{ tType, tID string } + var targets []shareTarget + for rows.Next() { + var st shareTarget + if err := rows.Scan(&st.tType, &st.tID); err == nil { + targets = append(targets, st) + } + } + _ = rows.Close() + + for _, st := range targets { + if st.tType == "user" && st.tID != "" { + userSet[st.tID] = true + } else if st.tType == "team" && st.tID != "" { + mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, st.tID) + if err == nil { + for mRows.Next() { + var uid string + if err := mRows.Scan(&uid); err == nil && uid != "" { + userSet[uid] = true + } + } + _ = mRows.Close() + } + } + } + } + + users := make([]string, 0, len(userSet)) + for uid := range userSet { + users = append(users, uid) + } + return users, nil +} + +func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) { + if strings.TrimSpace(title) == "" { + title = "Unbenannte Notiz" + } + if icon == "" { + icon = "📄" + } + if blocks == nil { + blocks = []domain.DocumentBlock{ + {ID: domain.NewID("blk"), Type: "paragraph", Content: ""}, + } + } + tags = stripFolderTags(tags) + + var plainBuilder strings.Builder + for _, b := range blocks { + if b.Content != "" { + plainBuilder.WriteString(b.Content) + plainBuilder.WriteString("\n") + } + } + + blocksJSON, _ := json.Marshal(blocks) + tagsJSON, _ := json.Marshal(tags) + now := domain.NowISO() + pinnedInt := 0 + if isPinned { + pinnedInt = 1 + } + + cleanFolder := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) + if cleanFolder == "." { + cleanFolder = "" + } + + mounts, _, _ := s.getUserFolderMounts(userID) + var sharedFolderID, subFolderPath string + if fID, sub, ok := matchMountPath(cleanFolder, mounts); ok { + sharedFolderID = fID + subFolderPath = sub + } + + doc := &domain.Document{ + ID: domain.NewID("doc"), + UserID: userID, + SpaceID: spaceID, + Title: strings.TrimSpace(title), + Icon: icon, + Content: plainBuilder.String(), + Blocks: blocks, + Tags: tags, + FolderPath: cleanFolder, + IsPinned: isPinned, + SharedFolderID: sharedFolderID, + SubFolderPath: subFolderPath, + IsSharedFolder: sharedFolderID != "", + Permission: "owner", + IsOwner: true, + CreatedAt: now, + UpdatedAt: now, + } + + _, err := s.db.Exec( + `INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at, shared_folder_id, sub_folder_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, + doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt, doc.SharedFolderID, doc.SubFolderPath, + ) + if err != nil { + return nil, err + } + if isPinned { + _, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt) + } + s.replaceDocBlocksInDB(doc.ID, userID, doc.Blocks, now) + _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) + return doc, nil +} + +// getUserFolderMounts returns map[folder_id]mount_path and map[folder_id]folder_name for all shared folders accessible to userID +func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[string]string, error) { + mounts := make(map[string]string) + names := make(map[string]string) + + rows, err := s.db.Query(` + SELECT f.id, f.name, COALESCE(m.mount_path, f.name) as mount_path + FROM workspace_shared_folders f + LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? + LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND ( + (s.target_type = 'user' AND s.target_id = ?) OR + (s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE f.owner_id = ? OR s.id IS NOT NULL + GROUP BY f.id + `, userID, userID, userID, userID) + if err != nil { + return mounts, names, err + } + defer rows.Close() + + for rows.Next() { + var fID, fName, mPath string + if err := rows.Scan(&fID, &fName, &mPath); err == nil { + names[fID] = fName + if mPath == "" { + mPath = fName + } + mounts[fID] = mPath + } + } + return mounts, names, nil +} + +// matchMountPath checks if folderPath falls within any mounted shared folder. +// Returns (sharedFolderID, relativeSubPath, isMatch). +func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) { + clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) + if clean == "." { + clean = "" + } + if clean == "" { + return "", "", false + } + + var bestFolderID string + var bestSubPath string + var longestMountLen int = -1 + + for fID, mPath := range mounts { + cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mPath))) + if cleanMount == "." { + cleanMount = "" + } + if cleanMount == "" { + continue + } + + if clean == cleanMount { + if len(cleanMount) > longestMountLen { + longestMountLen = len(cleanMount) + bestFolderID = fID + bestSubPath = "" + } + } else if strings.HasPrefix(clean, cleanMount+"/") { + sub := strings.TrimPrefix(clean, cleanMount+"/") + if len(cleanMount) > longestMountLen { + longestMountLen = len(cleanMount) + bestFolderID = fID + bestSubPath = sub + } + } + } + + if longestMountLen >= 0 { + return bestFolderID, bestSubPath, true + } + return "", "", false +} + +func resolveDocumentPath(userID string, doc *domain.Document, mounts map[string]string, names map[string]string, userFolders map[string]string) { + if doc.SharedFolderID != "" { + mountPath, ok := mounts[doc.SharedFolderID] + if !ok || mountPath == "" { + mountPath = names[doc.SharedFolderID] + if mountPath == "" { + mountPath = "Geteilter Ordner" + } + } + doc.IsSharedFolder = true + if doc.SubFolderPath != "" { + doc.FolderPath = mountPath + "/" + doc.SubFolderPath + } else { + doc.FolderPath = mountPath + } + } else { + if uf, ok := userFolders[doc.ID]; ok { + doc.FolderPath = uf + } else if !doc.IsOwner { + doc.FolderPath = "" + } + } +} + +func scanDocument(rows interface { + Scan(dest ...any) error +}) (*domain.Document, error) { + d := &domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + err := rows.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + ) + if err != nil { + return nil, err + } + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Blocks = []domain.DocumentBlock{} + d.Tags = []string{} + _ = json.Unmarshal([]byte(blocksStr), &d.Blocks) + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + return d, nil +} + +func (s *Store) scanDocumentWithBlocks(rows interface { + Scan(dest ...any) error +}) (*domain.Document, error) { + d := &domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + err := rows.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + ) + if err != nil { + return nil, err + } + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + d.Blocks = s.loadDocBlocks(d.ID, blocksStr) + d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks) + d.FolderID = d.FolderPath + return d, nil +} + +func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { + mounts, names, _ := s.getUserFolderMounts(userID) + + userFolders := make(map[string]string) + ufRows, err := s.db.Query(`SELECT doc_id, folder_path FROM doc_user_folders WHERE user_id = ?`, userID) + if err == nil { + for ufRows.Next() { + var did, fp string + if err := ufRows.Scan(&did, &fp); err == nil { + userFolders[did] = fp + } + } + _ = ufRows.Close() + } + + // 1. Fetch user's own documents (with per-user is_pinned from doc_pins) + rows, err := s.db.Query( + `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, + CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, + d.is_archived, d.created_at, d.updated_at, + d.shared_folder_id, d.sub_folder_path + FROM documents d + LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? + WHERE d.user_id = ? AND d.is_archived = 0 + ORDER BY is_pinned DESC, d.updated_at DESC`, + userID, userID, + ) + if err != nil { + return nil, err + } + + type rawDocRow struct { + doc domain.Document + blocksStr string + } + var ownRaw []rawDocRow + for rows.Next() { + d := domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + if err := rows.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + ); err == nil { + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + d.Permission = "owner" + d.IsOwner = true + resolveDocumentPath(userID, &d, mounts, names, userFolders) + ownRaw = append(ownRaw, rawDocRow{doc: d, blocksStr: blocksStr}) + } + } + _ = rows.Close() + + docs := make([]domain.Document, 0, len(ownRaw)) + for _, r := range ownRaw { + r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) + docs = append(docs, r.doc) + } + + // 2. Fetch documents shared with the user directly, via teams, or via shared folders + sharedRows, err := s.db.Query( + `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, + CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, + d.is_archived, d.created_at, d.updated_at, + d.shared_folder_id, d.sub_folder_path, + CASE + WHEN wsf.owner_id = ? THEN 'owner' + WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write' + ELSE 'read' + END AS permission, + u.display_name + FROM documents d + JOIN users u ON d.user_id = u.id + LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ? + LEFT JOIN ( + SELECT dts2.doc_id, dts2.permission + FROM doc_team_shares dts2 + JOIN team_members tm ON tm.team_id = dts2.team_id + WHERE tm.user_id = ? + ) dts ON d.id = dts.doc_id + LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id + LEFT JOIN ( + SELECT wfs2.folder_id, wfs2.permission + FROM workspace_folder_shares wfs2 + WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) + OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) wfs ON d.shared_folder_id = wfs.folder_id + LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? + WHERE d.is_archived = 0 AND d.user_id != ? AND ( + s.user_id IS NOT NULL OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL + ) + GROUP BY d.id + ORDER BY is_pinned DESC, d.updated_at DESC`, + userID, userID, userID, userID, userID, userID, userID, userID, + ) + if err == nil { + var sharedRaw []rawDocRow + for sharedRows.Next() { + d := domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + var perm, ownerName string + if scanErr := sharedRows.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + &perm, &ownerName, + ); scanErr == nil { + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + d.Permission = perm + d.IsOwner = (perm == "owner") + d.OwnerName = ownerName + resolveDocumentPath(userID, &d, mounts, names, userFolders) + sharedRaw = append(sharedRaw, rawDocRow{doc: d, blocksStr: blocksStr}) + } + } + _ = sharedRows.Close() + + for _, r := range sharedRaw { + r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) + docs = append(docs, r.doc) + } + } + + // 3. Attach shares to owned documents + for i := range docs { + if docs[i].IsOwner { + if shares, err := s.GetDocShares(docs[i].ID); err == nil { + docs[i].SharedWith = shares + } + } + } + + return docs, nil +} + +func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) { + mounts, names, _ := s.getUserFolderMounts(userID) + userFolders := make(map[string]string) + var personalFp string + if err := s.db.QueryRow(`SELECT folder_path FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID).Scan(&personalFp); err == nil { + userFolders[docID] = personalFp + } + + // Try owned document first (with per-user is_pinned) + row := s.db.QueryRow( + `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, + CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, + d.is_archived, d.created_at, d.updated_at, + d.shared_folder_id, d.sub_folder_path + FROM documents d + LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? + WHERE d.id = ? AND (d.user_id = ? OR (d.is_archived = 1 AND d.archived_by = ?))`, + userID, docID, userID, userID, + ) + doc, err := s.scanDocumentWithBlocks(row) + if err == nil { + doc.Permission = "owner" + doc.IsOwner = doc.UserID == userID + if doc.IsArchived { + doc.Permission = "read" // read-only while in the trash + } + resolveDocumentPath(userID, doc, mounts, names, userFolders) + if shares, err := s.GetDocShares(doc.ID); err == nil { + doc.SharedWith = shares + } + return doc, nil + } + + // Try shared document (direct, via team, or via shared folder) + sharedRow := s.db.QueryRow( + `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, + CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, + d.is_archived, d.created_at, d.updated_at, + d.shared_folder_id, d.sub_folder_path, + CASE + WHEN wsf.owner_id = ? THEN 'owner' + WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write' + ELSE 'read' + END AS permission, + u.display_name + FROM documents d + JOIN users u ON d.user_id = u.id + LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ? + LEFT JOIN ( + SELECT dts2.doc_id, dts2.permission + FROM doc_team_shares dts2 + JOIN team_members tm ON tm.team_id = dts2.team_id + WHERE tm.user_id = ? + ) dts ON d.id = dts.doc_id + LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id + LEFT JOIN ( + SELECT wfs2.folder_id, wfs2.permission + FROM workspace_folder_shares wfs2 + WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) + OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) wfs ON d.shared_folder_id = wfs.folder_id + LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? + WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL) + LIMIT 1`, + userID, userID, userID, userID, userID, userID, docID, userID, userID, + ) + d := &domain.Document{} + var blocksStr, tagsStr string + var isPinned, isArchived int + var perm, ownerName string + err = sharedRow.Scan( + &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, + &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, + &d.SharedFolderID, &d.SubFolderPath, + &perm, &ownerName, + ) + if err != nil { + return nil, err + } + d.IsPinned = isPinned != 0 + d.IsArchived = isArchived != 0 + d.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &d.Tags) + d.Tags = stripFolderTags(d.Tags) + d.Blocks = s.loadDocBlocks(d.ID, blocksStr) + d.Permission = perm + d.IsOwner = (perm == "owner") + d.OwnerName = ownerName + resolveDocumentPath(userID, d, mounts, names, userFolders) + if shares, err := s.GetDocShares(d.ID); err == nil { + d.SharedWith = shares + } + return d, nil +} + +func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, isPinned *bool) (*domain.Document, error) { + doc, err := s.GetDocumentByID(userID, docID) + if err != nil { + return nil, err + } + if doc.IsArchived { + return nil, ErrDocInTrash + } + + // Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is + // only changed through the operation log (POST /docs/{id}/sync). + changesContent := title != nil || icon != nil + if changesContent && !CanEditDoc(s.DocRole(userID, docID)) { + return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") + } + + if isPinned != nil { + doc.IsPinned = *isPinned + if *isPinned { + _, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO()) + if doc.IsOwner { + _, _ = s.db.Exec(`UPDATE documents SET is_pinned = 1 WHERE id = ?`, docID) + } + } else { + _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID) + if doc.IsOwner { + _, _ = s.db.Exec(`UPDATE documents SET is_pinned = 0 WHERE id = ?`, docID) + } + } + } + + if folderPath != nil { + movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath) + if moveErr != nil { + return nil, moveErr + } + doc.FolderPath = movedDoc.FolderPath + doc.SharedFolderID = movedDoc.SharedFolderID + doc.SubFolderPath = movedDoc.SubFolderPath + doc.IsSharedFolder = movedDoc.IsSharedFolder + } + + if !changesContent { + return doc, nil + } + if title != nil { + doc.Title = strings.TrimSpace(*title) + s.applyLegacyTitle(docID, userID, doc.Title) + } + if icon != nil { + doc.Icon = *icon + } + doc.UpdatedAt = domain.NowISO() + if _, err := s.db.Exec(`UPDATE documents SET icon = ?, updated_at = ? WHERE id = ?`, doc.Icon, doc.UpdatedAt, docID); err != nil { + return nil, err + } + return s.GetDocumentByID(userID, docID) +} + +func (s *Store) DeleteDocument(userID, docID string) error { + doc, err := s.GetDocumentByID(userID, docID) + if err != nil { + return err + } + if !doc.IsOwner { + // Collaborator removing their shared access + _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) + return s.DeleteDocShare(userID, docID, userID) + } + if doc.IsArchived { + return s.hardDeleteDocument(docID) + } + return s.trashDocument(userID, docID) +} + +func (s *Store) reindexAllDocBlockTags() error { + rows, err := s.db.Query(`SELECT id, user_id, blocks_json FROM documents WHERE is_archived = 0`) + if err != nil { + return err + } + defer rows.Close() + + type docInfo struct { + id, userID, blocksJSON string + } + var docs []docInfo + for rows.Next() { + var d docInfo + if err := rows.Scan(&d.id, &d.userID, &d.blocksJSON); err == nil { + docs = append(docs, d) + } + } + _ = rows.Close() + + for _, d := range docs { + blocks := s.loadDocBlocks(d.id, d.blocksJSON) + if len(blocks) > 0 { + _ = s.indexDocBlockTags(d.id, d.userID, blocks) + } + } + return nil +} + +func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error { + _, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID) + if len(blocks) == 0 { + return nil + } + + stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`) + if err != nil { + return err + } + defer stmt.Close() + + for _, b := range blocks { + if b.Content == "" { + continue + } + tags := domain.ExtractTags(b.Content) + for _, tag := range tags { + _, _ = stmt.Exec(tag, docID, b.ID) + } + } + return nil +} + +func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) { + shares := make([]domain.DocShare, 0) + + // 1. Direct user shares + uRows, err := s.db.Query( + `SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at + FROM doc_shares s + JOIN users u ON s.user_id = u.id + WHERE s.doc_id = ? + ORDER BY s.created_at ASC`, + docID, + ) + if err == nil { + for uRows.Next() { + var sh domain.DocShare + if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { + sh.TargetType = "user" + sh.TargetID = sh.UserID + sh.TargetName = sh.DisplayName + shares = append(shares, sh) + } + } + _ = uRows.Close() + } + + // 2. Team shares + tRows, err := s.db.Query( + `SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at + FROM doc_team_shares s + JOIN teams t ON s.team_id = t.id + WHERE s.doc_id = ? + ORDER BY s.created_at ASC`, + docID, + ) + if err == nil { + for tRows.Next() { + var sh domain.DocShare + var teamID, teamName string + if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { + sh.TargetType = "team" + sh.TargetID = teamID + sh.TargetName = teamName + sh.DisplayName = teamName + shares = append(shares, sh) + } + } + _ = tRows.Close() + } + + return shares, nil +} + +func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) { + doc, err := s.GetDocumentByID(currentUserID, docID) + if err != nil { + return nil, err + } + if !doc.IsOwner { + return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") + } + + cleanTeamID := strings.TrimSpace(teamID) + var teamName string + err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName) + if err != nil || !s.CanShareWith(currentUserID, "team", cleanTeamID) { + return nil, ErrTargetNotFound + } + + permission = normalizeSharePermission(permission) + + now := domain.NowISO() + shareID := domain.NewID("share") + _, err = s.db.Exec( + `INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`, + shareID, docID, cleanTeamID, permission, now, currentUserID, + ) + if err != nil { + return nil, err + } + + return &domain.DocShare{ + ID: shareID, + DocID: docID, + TargetType: "team", + TargetID: cleanTeamID, + TargetName: teamName, + DisplayName: teamName, + Permission: permission, + CreatedBy: currentUserID, + CreatedAt: now, + }, nil +} + +// normalizeSharePermission maps share roles from the API to the stored values: "write" (editor) or "read" (viewer). +func normalizeSharePermission(p string) string { + switch strings.ToLower(strings.TrimSpace(p)) { + case "write", "editor": + return "write" + } + return "read" +} + +func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) { + doc, err := s.GetDocumentByID(currentUserID, docID) + if err != nil { + return nil, err + } + if doc.IsArchived { + return nil, ErrDocInTrash + } + if !doc.IsOwner { + return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") + } + + cleanTarget := strings.TrimSpace(targetUserIdentifier) + + // If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare + if strings.HasPrefix(cleanTarget, "team_") { + return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) + } + + permission = normalizeSharePermission(permission) + + // Resolve target user by ID, username, or Email + var targetUser *domain.User + if strings.HasPrefix(cleanTarget, "usr_") { + targetUser, _ = s.GetUserByID(cleanTarget) + } + if targetUser == nil { + targetUser, _ = s.GetUserByLogin(cleanTarget) + } + if targetUser == nil { + targetUser, err = s.GetUserByID(cleanTarget) + } + if err != nil || targetUser == nil { + // Check if it's a team ID that doesn't start with team_ + var tName string + if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil { + return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) + } + return nil, ErrTargetNotFound + } + + if targetUser.ID == doc.UserID { + return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer") + } + if !s.CanShareWith(currentUserID, "user", targetUser.ID) { + return nil, ErrTargetNotFound + } + + now := domain.NowISO() + shareID := domain.NewID("share") + _, err = s.db.Exec( + `INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(doc_id, user_id) DO UPDATE SET permission = excluded.permission`, + shareID, docID, targetUser.ID, permission, now, currentUserID, + ) + if err != nil { + return nil, err + } + + return &domain.DocShare{ + ID: shareID, + DocID: docID, + TargetType: "user", + TargetID: targetUser.ID, + TargetName: targetUser.DisplayName, + UserID: targetUser.ID, + UserEmail: targetUser.Email, + DisplayName: targetUser.DisplayName, + Permission: permission, + CreatedBy: currentUserID, + CreatedAt: now, + }, nil +} + +func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error { + doc, err := s.GetDocumentByID(currentUserID, docID) + if err != nil { + return err + } + // Only the owner manages shares; everyone else can only remove their own + if !doc.IsOwner && currentUserID != targetID { + return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe") + } + + cleanTarget := strings.TrimSpace(targetID) + // Try deleting team share first + res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget) + if err == nil { + if rows, _ := res.RowsAffected(); rows > 0 { + return nil + } + } + + // Fallback to user share + _, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget) + return err +} + +// GetCollaborators returns the active people the user can share with (everyone visible except the user). +func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { + visible, err := s.GetDiscoverableUsers(currentUserID) + if err != nil { + return nil, err + } + users := make([]domain.User, 0, len(visible)) + for _, u := range visible { + if u.ID != currentUserID && u.IsActive { + users = append(users, u) + } + } + return users, nil +} diff --git a/internal/storage/folders.go b/internal/storage/folders.go new file mode 100644 index 0000000..5d0250a --- /dev/null +++ b/internal/storage/folders.go @@ -0,0 +1,862 @@ +package storage + +import ( + "database/sql" + "fmt" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +func (s *Store) GetFolders(userID string) ([]string, error) { + folderSet := make(map[string]bool) + + // 1. Collect from resolved documents for this user + docs, err := s.GetDocuments(userID) + if err == nil { + for _, doc := range docs { + if doc.FolderPath != "" { + addFolderSegments(doc.FolderPath, folderSet) + } + } + } + + // 2. Add mounted shared folders (so even empty shared folders appear in the tree) + mounts, _, err := s.getUserFolderMounts(userID) + if err == nil { + for sfID, mPath := range mounts { + if mPath != "" { + addFolderSegments(mPath, folderSet) + + // 2b. Add all subfolders created in this shared folder + sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID) + if err == nil { + for sRows.Next() { + var sp string + if err := sRows.Scan(&sp); err == nil && sp != "" { + fullP := mPath + "/" + sp + addFolderSegments(fullP, folderSet) + } + } + _ = sRows.Close() + } + } + } + } + + // 3. Add personal empty folders created by this user + pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID) + if err == nil { + for pRows.Next() { + var p string + if err := pRows.Scan(&p); err == nil && p != "" { + addFolderSegments(p, folderSet) + } + } + _ = pRows.Close() + } + + result := make([]string, 0, len(folderSet)) + for f := range folderSet { + result = append(result, f) + } + sort.Strings(result) + return result, nil +} + +func addFolderSegments(fp string, set map[string]bool) { + clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(fp))) + if clean == "." || clean == "" { + return + } + parts := strings.Split(clean, "/") + var cur string + for _, p := range parts { + if p != "" && p != "." { + if cur == "" { + cur = p + } else { + cur = cur + "/" + p + } + set[cur] = true + } + } +} + +func (s *Store) CreateFolder(userID, folderPath string) (string, error) { + clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) + if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") { + return "", fmt.Errorf("ungültiger Ordnerpfad") + } + + mounts, _, err := s.getUserFolderMounts(userID) + var sfID, subPath string + if err == nil { + sfID, subPath, _ = matchMountPath(clean, mounts) + } + + now := time.Now().UTC().Format(time.RFC3339) + id := domain.NewID("wfld") + + if sfID != "" { + var existsID string + err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID) + if err == sql.ErrNoRows { + _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + id, userID, sfID, clean, subPath, now) + } + } else { + var existsID string + err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID) + if err == sql.ErrNoRows { + _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`, + id, userID, clean, now) + } + } + + return clean, nil +} + +func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { + clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) + if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") { + return nil, fmt.Errorf("ungültiger Ordnerpfad") + } + + var deletedDocIDs []string + seenDocID := make(map[string]bool) + + mounts, _, err := s.getUserFolderMounts(userID) + if err == nil { + sfID, subPath, isMatch := matchMountPath(clean, mounts) + if isMatch && sfID != "" { + if subPath == "" { + // Root of a shared folder + shFolder, sfErr := s.GetSharedFolderByID(userID, sfID) + if sfErr == nil && !shFolder.IsOwner { + // Collaborator removing mount/share + _ = s.DeleteSharedFolder(userID, sfID) + return []string{}, nil + } + rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID) + if qErr == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + } + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID) + } else { + // Subfolder inside a shared folder + rows, qErr := s.db.Query( + `SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`, + sfID, subPath, subPath+"/%", + ) + if qErr == nil { + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + } + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`, + sfID, subPath, subPath+"/%") + } + } + } + + // Find all personal documents owned by this user in this folder and subfolders + rows, err := s.db.Query( + `SELECT id FROM documents + WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`, + userID, clean, clean+"/%", + ) + if err != nil { + return nil, err + } + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && !seenDocID[id] { + seenDocID[id] = true + deletedDocIDs = append(deletedDocIDs, id) + } + } + _ = rows.Close() + + // The notes of the folder go to the trash; restoring one creates its folder again + for _, id := range deletedDocIDs { + _ = s.trashDocument(userID, id) + } + + // Also clear any personal folder placements for shared notes in this folder + _, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%") + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") + + return deletedDocIDs, nil +} + +func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Document, error) { + doc, err := s.GetDocumentByID(userID, docID) + if err != nil { + return nil, err + } + if doc.IsArchived { + return nil, ErrDocInTrash + } + + cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath))) + if cleanTarget == "." { + cleanTarget = "" + } + + mounts, _, err := s.getUserFolderMounts(userID) + if err != nil { + return nil, err + } + + now := domain.NowISO() + + // Check if target folder falls within any mounted shared folder + targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts) + + if isTargetShared { + // Moving into / within a shared folder + shFolder, err := s.GetSharedFolderByID(userID, targetFolderID) + if err != nil { + return nil, err + } + if !shFolder.IsOwner { + return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben") + } + + // Update document to belong to targetSharedFolderID + _, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`, + targetFolderID, subPath, now, docID) + if err != nil { + return nil, err + } + + // Remove personal override if any + _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) + + doc.SharedFolderID = targetFolderID + doc.SubFolderPath = subPath + doc.IsSharedFolder = true + doc.FolderPath = cleanTarget + return doc, nil + } + + // Target is NOT inside a shared folder (personal / standalone) + if doc.SharedFolderID != "" { + // Moving OUT of a shared folder into a personal folder + if !doc.IsOwner { + return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben") + } + _, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`, + cleanTarget, now, docID) + if err != nil { + return nil, err + } + doc.SharedFolderID = "" + doc.SubFolderPath = "" + doc.IsSharedFolder = false + doc.FolderPath = cleanTarget + return doc, nil + } + + // Standalone note + if doc.IsOwner { + // Owner moves their own document -> updates documents.folder_path + _, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID) + if err != nil { + return nil, err + } + doc.FolderPath = cleanTarget + return doc, nil + } + + // Recipient moves an individually shared note -> updates doc_user_folders ONLY! + _, err = s.db.Exec(` + INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at + `, userID, docID, cleanTarget, now, now) + if err != nil { + return nil, err + } + + doc.FolderPath = cleanTarget + return doc, nil +} + +// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path. +func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) { + cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath))) + if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") { + return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad") + } + cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath))) + if cleanTarget == "." { + cleanTarget = "" + } + if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) { + return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden") + } + + folderName := filepath.Base(cleanSource) + var newFolderPath string + if cleanTarget == "" { + newFolderPath = folderName + } else { + newFolderPath = cleanTarget + "/" + folderName + } + + if newFolderPath == cleanSource { + return cleanSource, []string{}, nil + } + + // 1. Check if sourcePath is the root mount of a shared folder + mounts, _, err := s.getUserFolderMounts(userID) + if err == nil { + for sfID, mPath := range mounts { + if mPath == cleanSource { + err := s.UpdateFolderMount(userID, sfID, newFolderPath) + if err != nil { + return "", nil, err + } + return newFolderPath, []string{}, nil + } + } + } + + // 2. Move all affected documents + docs, err := s.GetDocuments(userID) + if err != nil { + return "", nil, err + } + + var movedDocIDs []string + for _, doc := range docs { + if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") { + var nextDocFolder string + if doc.FolderPath == cleanSource { + nextDocFolder = newFolderPath + } else { + suffix := strings.TrimPrefix(doc.FolderPath, cleanSource) + nextDocFolder = newFolderPath + suffix + } + _, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder) + if moveErr == nil { + movedDocIDs = append(movedDocIDs, doc.ID) + } + } + } + + // 3. Re-map workspace_folders entries + targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts) + sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts) + + wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`) + if qErr == nil { + type wfRecord struct { + id, uid, sfID, path, subPath string + } + var records []wfRecord + for wfRows.Next() { + var r wfRecord + if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil { + records = append(records, r) + } + } + _ = wfRows.Close() + + for _, r := range records { + if isSourceSF && r.sfID == sourceSFID { + matches := false + var suffix string + if sourceSub == "" { + matches = true + suffix = "/" + r.subPath + } else if r.subPath == sourceSub { + matches = true + suffix = "" + } else if strings.HasPrefix(r.subPath, sourceSub+"/") { + matches = true + suffix = strings.TrimPrefix(r.subPath, sourceSub) + } + if matches { + var newSub string + if targetSub == "" { + newSub = strings.TrimPrefix(folderName+suffix, "/") + } else { + newSub = targetSub + "/" + folderName + suffix + } + if isTargetSF { + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id) + } else { + newP := newFolderPath + suffix + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id) + } + } + } else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") { + suffix := strings.TrimPrefix(r.path, cleanSource) + newP := newFolderPath + suffix + if isTargetSF { + var newSub string + if targetSub == "" { + newSub = strings.TrimPrefix(folderName+suffix, "/") + } else { + newSub = targetSub + "/" + folderName + suffix + } + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id) + } else { + _, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id) + } + } + } + } + + _, _ = s.CreateFolder(userID, newFolderPath) + + return newFolderPath, movedDocIDs, nil +} + +// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID. +func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { + if ownerID == "" || folderID == "" || cleanMount == "" { + return + } + + // 1. Link all documents owned by ownerID in cleanMount to this shared folder + rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`, + ownerID, cleanMount, cleanMount+"/%") + if err == nil { + type docEntry struct{ id, fp string } + var entries []docEntry + for rows.Next() { + var de docEntry + if err := rows.Scan(&de.id, &de.fp); err == nil { + entries = append(entries, de) + } + } + _ = rows.Close() + + for _, de := range entries { + var subPath string + if de.fp == cleanMount { + subPath = "" + } else if strings.HasPrefix(de.fp, cleanMount+"/") { + subPath = strings.TrimPrefix(de.fp, cleanMount+"/") + } + _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`, + folderID, subPath, de.id) + } + } + + // 2. Link any workspace_folders created by ownerID in cleanMount + fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, + ownerID, cleanMount, cleanMount+"/%") + if err == nil { + type wfEntry struct{ id, path string } + var wfEntries []wfEntry + for fRows.Next() { + var wfe wfEntry + if err := fRows.Scan(&wfe.id, &wfe.path); err == nil { + wfEntries = append(wfEntries, wfe) + } + } + _ = fRows.Close() + + for _, wfe := range wfEntries { + subP := "" + if strings.HasPrefix(wfe.path, cleanMount+"/") { + subP = strings.TrimPrefix(wfe.path, cleanMount+"/") + } + _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, + folderID, subP, wfe.id) + } + } +} + +func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) { + cleanName := strings.TrimSpace(folderName) + if cleanName == "" { + return nil, fmt.Errorf("ordnername darf nicht leer sein") + } + cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath))) + if cleanMount == "." || cleanMount == "" { + cleanMount = cleanName + } + + // Check if already shared by this user at this mount path + var existingID string + err := s.db.QueryRow(` + SELECT f.id FROM workspace_shared_folders f + JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? + WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?) + `, userID, userID, cleanMount, cleanName).Scan(&existingID) + if err == nil && existingID != "" { + s.syncSharedFolderContents(userID, existingID, cleanMount) + return s.GetSharedFolderByID(userID, existingID) + } + + now := domain.NowISO() + folderID := domain.NewID("fld") + + _, err = s.db.Exec(` + INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, folderID, userID, cleanName, now, now) + if err != nil { + return nil, err + } + + _, err = s.db.Exec(` + INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, userID, folderID, cleanMount, now, now) + if err != nil { + return nil, err + } + + s.syncSharedFolderContents(userID, folderID, cleanMount) + + var ownerName string + _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName) + + return &domain.SharedFolder{ + ID: folderID, + OwnerID: userID, + OwnerName: ownerName, + Name: cleanName, + MountPath: cleanMount, + IsOwner: true, + Permission: "owner", + Shares: []domain.FolderShare{}, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +func (s *Store) GetSharedFolders(userID string) ([]domain.SharedFolder, error) { + rows, err := s.db.Query(` + SELECT f.id, f.owner_id, u.display_name, f.name, + COALESCE(m.mount_path, f.name) as mount_path, + CASE + WHEN f.owner_id = ? THEN 1 + ELSE 0 + END as is_owner, + CASE + WHEN f.owner_id = ? THEN 'owner' + WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write' + ELSE 'read' + END as permission, + f.created_at, f.updated_at + FROM workspace_shared_folders f + JOIN users u ON f.owner_id = u.id + LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? + LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND ( + (s.target_type = 'user' AND s.target_id = ?) OR + (s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE f.owner_id = ? OR s.id IS NOT NULL + GROUP BY f.id + ORDER BY f.created_at ASC + `, userID, userID, userID, userID, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + folders := make([]domain.SharedFolder, 0) + for rows.Next() { + var f domain.SharedFolder + var isOwner int + if err := rows.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err == nil { + f.IsOwner = isOwner != 0 + if f.IsOwner { + if shares, err := s.GetFolderShares(f.ID); err == nil { + f.Shares = shares + } + } + folders = append(folders, f) + } + } + return folders, nil +} + +func (s *Store) GetSharedFolderByID(userID, folderID string) (*domain.SharedFolder, error) { + row := s.db.QueryRow(` + SELECT f.id, f.owner_id, u.display_name, f.name, + COALESCE(m.mount_path, f.name) as mount_path, + CASE + WHEN f.owner_id = ? THEN 1 + ELSE 0 + END as is_owner, + CASE + WHEN f.owner_id = ? THEN 'owner' + WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write' + ELSE 'read' + END as permission, + f.created_at, f.updated_at + FROM workspace_shared_folders f + JOIN users u ON f.owner_id = u.id + LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? + LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND ( + (s.target_type = 'user' AND s.target_id = ?) OR + (s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE f.id = ? AND (f.owner_id = ? OR s.id IS NOT NULL) + GROUP BY f.id + `, userID, userID, userID, userID, userID, folderID, userID) + + var f domain.SharedFolder + var isOwner int + if err := row.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err != nil { + return nil, fmt.Errorf("geteilter Ordner nicht gefunden") + } + f.IsOwner = isOwner != 0 + if f.IsOwner { + if shares, err := s.GetFolderShares(f.ID); err == nil { + f.Shares = shares + } + } + return &f, nil +} + +func (s *Store) UpdateFolderMount(userID, folderID, mountPath string) error { + cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mountPath))) + if cleanMount == "." || cleanMount == "" { + return fmt.Errorf("ungültiger Mount-Pfad") + } + now := domain.NowISO() + _, err := s.db.Exec(` + INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, folder_id) DO UPDATE SET mount_path = excluded.mount_path, updated_at = excluded.updated_at + `, userID, folderID, cleanMount, now, now) + return err +} + +func (s *Store) GetFolderShares(folderID string) ([]domain.FolderShare, error) { + shares := make([]domain.FolderShare, 0) + rows, err := s.db.Query(` + SELECT s.id, s.folder_id, s.target_type, s.target_id, + CASE + WHEN s.target_type = 'user' THEN u.display_name + WHEN s.target_type = 'team' THEN t.name + ELSE '' + END as target_name, + COALESCE(u.email, '') as email, + COALESCE(u.avatar_url, '') as avatar_url, + s.permission, s.created_by, s.created_at + FROM workspace_folder_shares s + LEFT JOIN users u ON s.target_type = 'user' AND s.target_id = u.id + LEFT JOIN teams t ON s.target_type = 'team' AND s.target_id = t.id + WHERE s.folder_id = ? + ORDER BY s.created_at ASC + `, folderID) + if err != nil { + return shares, err + } + defer rows.Close() + + for rows.Next() { + var sh domain.FolderShare + if err := rows.Scan(&sh.ID, &sh.FolderID, &sh.TargetType, &sh.TargetID, &sh.TargetName, &sh.Email, &sh.AvatarURL, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { + shares = append(shares, sh) + } + } + return shares, nil +} + +func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIdentifier, permission string) (*domain.FolderShare, error) { + f, err := s.GetSharedFolderByID(currentUserID, folderID) + if err != nil { + return nil, err + } + if !f.IsOwner { + return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten") + } + + cleanType := strings.TrimSpace(targetType) + cleanTarget := strings.TrimSpace(targetIdentifier) + if cleanType == "" { + if strings.HasPrefix(cleanTarget, "team_") { + cleanType = "team" + } else { + cleanType = "user" + } + } + + permission = normalizeSharePermission(permission) + + var targetID, targetName, email, avatarURL string + if cleanType == "team" { + err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) + if err != nil { + return nil, ErrTargetNotFound + } + } else { + var u *domain.User + if strings.HasPrefix(cleanTarget, "usr_") { + u, _ = s.GetUserByID(cleanTarget) + } + if u == nil { + u, _ = s.GetUserByLogin(cleanTarget) + } + if u == nil { + u, err = s.GetUserByID(cleanTarget) + } + if err != nil || u == nil { + if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil { + cleanType = "team" + } else { + return nil, ErrTargetNotFound + } + } else { + if u.ID == f.OwnerID { + return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer") + } + targetID = u.ID + targetName = u.DisplayName + email = u.Email + avatarURL = u.AvatarURL + } + } + + if !s.CanShareWith(currentUserID, cleanType, targetID) { + return nil, ErrTargetNotFound + } + + now := domain.NowISO() + shareID := domain.NewID("fsh") + + _, err = s.db.Exec(` + INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission + `, shareID, folderID, cleanType, targetID, permission, currentUserID, now) + if err != nil { + return nil, err + } + + var ownerMount string + _ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount) + if ownerMount == "" { + ownerMount = f.Name + } + s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount) + + if cleanType == "user" { + _, _ = s.db.Exec(` + INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, targetID, folderID, f.Name, now, now) + } else if cleanType == "team" { + mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID) + if err == nil { + var memberIDs []string + for mRows.Next() { + var uid string + if err := mRows.Scan(&uid); err == nil && uid != "" { + memberIDs = append(memberIDs, uid) + } + } + _ = mRows.Close() + for _, uid := range memberIDs { + _, _ = s.db.Exec(` + INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, uid, folderID, f.Name, now, now) + } + } + } + + return &domain.FolderShare{ + ID: shareID, + FolderID: folderID, + TargetType: cleanType, + TargetID: targetID, + TargetName: targetName, + Email: email, + AvatarURL: avatarURL, + Permission: permission, + CreatedBy: currentUserID, + CreatedAt: now, + }, nil +} + +func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error { + f, err := s.GetSharedFolderByID(currentUserID, folderID) + if err != nil { + return err + } + cleanID := strings.TrimSpace(shareID) + + // Only the owner manages shares; everyone else can only leave the folder + if !f.IsOwner { + _, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`, + folderID, currentUserID, cleanID, cleanID) + _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID) + return err + } + + var targetType, targetID string + _ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, + folderID, cleanID, cleanID).Scan(&targetType, &targetID) + + _, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID) + if err != nil { + return err + } + if targetType == "user" && targetID != "" { + _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID) + } + return nil +} + +func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error { + f, err := s.GetSharedFolderByID(currentUserID, folderID) + if err != nil { + return err + } + if !f.IsOwner { + return s.DeleteFolderShare(currentUserID, folderID, currentUserID) + } + + _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID) + _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID) + _, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID) + _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID) + _, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID) + return err +} + +// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder. +func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) { + rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID) + if err != nil { + return nil, err + } + defer rows.Close() + + var docIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil && id != "" { + docIDs = append(docIDs, id) + } + } + return docIDs, nil +} diff --git a/internal/storage/lists.go b/internal/storage/lists.go new file mode 100644 index 0000000..6a92a74 --- /dev/null +++ b/internal/storage/lists.go @@ -0,0 +1,502 @@ +package storage + +import ( + "database/sql" + "errors" + "regexp" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// --- STANDALONE REALTIME LISTS & SHARING --- + +func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) { + if strings.TrimSpace(title) == "" { + return nil, errors.New("list title required") + } + if icon == "" { + icon = "🛒" + } + if listType == "" { + listType = "shopping" + } + now := domain.NowISO() + lst := &domain.SharedList{ + ID: domain.NewID("lst"), + UserID: userID, + IsOwner: true, + Role: "editor", + Title: strings.TrimSpace(title), + Icon: icon, + ListType: listType, + Items: []domain.ListItem{}, + Shares: []domain.ListShare{}, + UpdatedAt: now, + CreatedAt: now, + } + + _, err := s.db.Exec( + `INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, + ) + if err != nil { + return nil, err + } + _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName) + return lst, nil +} + +func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) { + if targetType != "team" && targetType != "user" { + return nil, errors.New("ungültiger Freigabetyp") + } + targetID = strings.TrimSpace(targetID) + if targetID == "" { + return nil, errors.New("Ziel-ID erforderlich") + } + if role != "viewer" && role != "editor" { + role = "editor" + } + + if targetType == "user" { + var targetUser *domain.User + if strings.HasPrefix(targetID, "usr_") { + targetUser, _ = s.GetUserByID(targetID) + } + if targetUser == nil { + targetUser, _ = s.GetUserByLogin(targetID) + } + if targetUser == nil { + targetUser, _ = s.GetUserByEmail(targetID) + } + if targetUser == nil { + return nil, ErrTargetNotFound + } + targetID = targetUser.ID + } + + // Only the owner shares + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return nil, errors.New("liste nicht gefunden") + } + if ownerID != currentUserID { + return nil, errors.New("nur der Eigentümer kann die Liste freigeben") + } + if !s.CanShareWith(currentUserID, targetType, targetID) { + return nil, ErrTargetNotFound + } + + now := domain.NowISO() + share := &domain.ListShare{ + ID: domain.NewID("lsh"), + ListID: listID, + TargetType: targetType, + TargetID: targetID, + Role: role, + CreatedBy: currentUserID, + CreatedAt: now, + } + + _, err = s.db.Exec( + `INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`, + share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt, + ) + if err != nil { + return nil, err + } + + _ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`, + listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt) + + if targetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email) + } + return share, nil +} + +func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error { + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return errors.New("liste nicht gefunden") + } + if ownerID != currentUserID { + return errors.New("nur der Eigentümer kann Freigaben entfernen") + } + + cleanTarget := strings.TrimSpace(shareIDOrTargetID) + _, err = s.db.Exec( + `DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`, + listID, cleanTarget, cleanTarget, + ) + return err +} + +func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) { + rows, err := s.db.Query( + `SELECT id, list_id, target_type, target_id, role, created_by, created_at + FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`, + listID, + ) + if err != nil { + return []domain.ListShare{}, err + } + defer rows.Close() + + shares := make([]domain.ListShare, 0) + for rows.Next() { + var sh domain.ListShare + if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil { + if sh.TargetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email) + } + shares = append(shares, sh) + } + } + return shares, nil +} + +func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { + rows, err := s.db.Query( + `SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, + CASE + WHEN l.user_id = ? THEN 'owner' + WHEN ls.role IS NOT NULL THEN ls.role + ELSE 'editor' + END as effective_role + FROM lists l + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.user_id = ? + OR ls.id IS NOT NULL + ORDER BY l.updated_at DESC`, + userID, userID, userID, userID, + ) + if err != nil { + return nil, err + } + + lists := make([]domain.SharedList, 0) + for rows.Next() { + var l domain.SharedList + var ownerName sql.NullString + var effectiveRole string + if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil { + l.OwnerName = ownerName.String + l.IsOwner = (l.UserID == userID) + l.Role = effectiveRole + lists = append(lists, l) + } + } + _ = rows.Close() + + for i := range lists { + lists[i].Items, _ = s.GetListItems(lists[i].ID) + lists[i].Shares, _ = s.GetListShares(lists[i].ID) + lists[i].Members = s.getListMembers(lists[i].ID, lists[i].UserID) + } + return lists, nil +} + +func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) { + var l domain.SharedList + var ownerName sql.NullString + var effectiveRole string + + var query string + var args []interface{} + if userID == "" { + query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, 'owner' as effective_role + FROM lists l + LEFT JOIN users u ON u.id = l.user_id + WHERE l.id = ?` + args = []interface{}{listID} + } else { + query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, + u.display_name, + CASE + WHEN l.user_id = ? THEN 'owner' + WHEN ls.role IS NOT NULL THEN ls.role + ELSE 'editor' + END as effective_role + FROM lists l + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.id = ? AND ( + l.user_id = ? + OR ls.id IS NOT NULL + )` + args = []interface{}{userID, userID, userID, listID, userID} + } + + err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole) + if err != nil { + return nil, err + } + l.OwnerName = ownerName.String + l.IsOwner = (userID == "" || l.UserID == userID) + l.Role = effectiveRole + l.Items, _ = s.GetListItems(l.ID) + l.Shares, _ = s.GetListShares(l.ID) + l.Members = s.getListMembers(l.ID, l.UserID) + return &l, nil +} + +// getListMembers returns the owner and everyone a list is shared with (directly or via a team). +func (s *Store) getListMembers(listID, ownerID string) []domain.TaskListMember { + members := make([]domain.TaskListMember, 0) + rows, err := s.db.Query( + `SELECT id, display_name, email, avatar_url FROM users WHERE id IN ( + SELECT user_id FROM lists WHERE id = ? + UNION SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'user' + UNION SELECT user_id FROM team_members WHERE team_id IN (SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'team') + ) ORDER BY display_name`, + listID, listID, listID, + ) + if err != nil { + return members + } + defer rows.Close() + for rows.Next() { + var m domain.TaskListMember + if rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL) == nil { + m.Role = "member" + if m.UserID == ownerID { + m.Role = "owner" + } + members = append(members, m) + } + } + return members +} + +func (s *Store) DeleteList(userID, listID string) error { + var ownerID string + err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) + if err != nil { + return errors.New("liste nicht gefunden") + } + if ownerID != userID { + return errors.New("nur der Eigentümer kann die Liste löschen") + } + _, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID) + return err +} + +func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) { + rows, err := s.db.Query( + `SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name, + li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at + FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != '' + WHERE li.list_id = ? + ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`, + listID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]domain.ListItem, 0) + for rows.Next() { + var it domain.ListItem + var doneInt int + if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil { + it.IsCompleted = doneInt != 0 + items = append(items, it) + } + } + return items, nil +} + +func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int, createdBy string) (*domain.ListItem, error) { + content = strings.TrimSpace(content) + if content == "" { + return nil, errors.New("item content cannot be empty") + } + + category = strings.TrimSpace(category) + // Auto-extract hashtag if category is empty + if category == "" && strings.Contains(content, "#") { + re := regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`) + if m := re.FindStringSubmatch(content); len(m) > 1 { + category = m[1] + cleaned := strings.TrimSpace(re.ReplaceAllString(content, "")) + if cleaned != "" { + content = cleaned + } + } + } else if category != "" { + category = strings.TrimPrefix(category, "#") + } + + now := domain.NowISO() + it := &domain.ListItem{ + ID: domain.NewID("itm"), + ListID: listID, + Content: content, + Category: category, + Quantity: strings.TrimSpace(quantity), + IsCompleted: false, + SortOrder: sortOrder, + CreatedBy: createdBy, + CreatedAt: now, + } + if createdBy != "" { + _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName) + } + + _, err := s.db.Exec( + `INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`, + it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy, + ) + if err != nil { + return nil, err + } + + _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, listID) + return it, nil +} + +func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName string) (*domain.ListItem, error) { + now := domain.NowISO() + doneInt := 0 + completedAt := "" + completedBy := "" + completedByName := "" + if isCompleted { + doneInt = 1 + completedAt = now + completedBy = userID + completedByName = userName + } + + _, err := s.db.Exec( + `UPDATE list_items SET is_completed = ?, completed_by = ?, completed_by_name = ?, completed_at = ? WHERE id = ?`, + doneInt, completedBy, completedByName, completedAt, itemID, + ) + if err != nil { + return nil, err + } + + var it domain.ListItem + var dInt int + err = s.db.QueryRow( + `SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at + FROM list_items WHERE id = ?`, + itemID, + ).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt) + if err != nil { + return nil, err + } + it.IsCompleted = dInt != 0 + _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID) + return &it, nil +} + +func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) { + now := domain.NowISO() + var it domain.ListItem + var dInt int + err := s.db.QueryRow( + `SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at + FROM list_items WHERE id = ?`, + itemID, + ).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt) + if err != nil { + return nil, err + } + it.IsCompleted = dInt != 0 + + if content != nil { + it.Content = *content + } + if category != nil { + it.Category = *category + } + if quantity != nil { + it.Quantity = *quantity + } + + _, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID) + if err != nil { + return nil, err + } + _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID) + return &it, nil +} + +func (s *Store) DeleteListItem(itemID string) (string, error) { + var listID string + _ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID) + _, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, itemID) + if listID != "" { + _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID) + } + return listID, err +} + +func (s *Store) ClearCompletedListItems(listID string) (int64, error) { + res, err := s.db.Exec(`DELETE FROM list_items WHERE list_id = ? AND is_completed = 1`, listID) + if err != nil { + return 0, err + } + _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID) + return res.RowsAffected() +} + +func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, it := range items { + if it.Category != nil && it.Content != nil { + _, err = tx.Exec( + `UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`, + it.SortOrder, *it.Category, *it.Content, it.ID, listID, + ) + } else if it.Category != nil { + _, err = tx.Exec( + `UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`, + it.SortOrder, *it.Category, it.ID, listID, + ) + } else if it.Content != nil { + _, err = tx.Exec( + `UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`, + it.SortOrder, *it.Content, it.ID, listID, + ) + } else { + _, err = tx.Exec( + `UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`, + it.SortOrder, it.ID, listID, + ) + } + if err != nil { + return err + } + } + + _, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID) + return tx.Commit() +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index f79b9d9..e81857c 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -4,15 +4,11 @@ import ( "context" "database/sql" "encoding/json" - "errors" "fmt" "os" "path/filepath" - "regexp" - "sort" "strings" "sync" - "time" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" @@ -546,826 +542,6 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) { return user, nil } -// --- USERS --- - -const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, organization_id, created_at` - -func parseAlarmChannels(raw string) []string { - parts := strings.Split(raw, ",") - res := make([]string, 0, len(parts)) - seen := make(map[string]bool) - for _, p := range parts { - p = strings.ToLower(strings.TrimSpace(p)) - if (p == "email" || p == "discord" || p == "webhook") && !seen[p] { - seen[p] = true - res = append(res, p) - } - } - return res -} - -func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) { - u := &domain.User{} - var isActiveInt int - var channelsRaw string - err := row.Scan( - &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName, - &u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail, - &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.OrganizationID, &u.CreatedAt, - ) - if err != nil { - return nil, err - } - u.IsActive = isActiveInt != 0 - if u.TokenVersion <= 0 { - u.TokenVersion = 1 - } - if u.Role == "" { - u.Role = "member" - } - u.AlarmChannels = parseAlarmChannels(channelsRaw) - if u.AlarmEmail == "" { - u.AlarmEmail = u.Email - } - return u, nil -} - -func (s *Store) CountUsers() (int, error) { - var count int - err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) - return count, err -} - -func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) { - cleanEmail := strings.ToLower(strings.TrimSpace(email)) - username := strings.Split(cleanEmail, "@")[0] - return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role) -} - -func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) { - if role != "admin" && role != "member" { - role = "member" - } - cleanEmail := strings.ToLower(strings.TrimSpace(email)) - cleanUsername := strings.ToLower(strings.TrimSpace(username)) - if cleanUsername == "" { - cleanUsername = strings.Split(cleanEmail, "@")[0] - } - u := &domain.User{ - ID: domain.NewID("usr"), - Email: cleanEmail, - Username: cleanUsername, - PasswordHash: passwordHash, - DisplayName: strings.TrimSpace(displayName), - Role: role, - IsActive: true, - TokenVersion: 1, - AlarmChannels: []string{}, - AlarmEmail: cleanEmail, - DiscordWebhookURL: "", - CustomWebhookURL: "", - NotifyBeforeMinutes: 30, - AvatarURL: "", - CreatedAt: domain.NowISO(), - } - _, err := s.db.Exec( - `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at) - VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`, - u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt, - ) - if err != nil { - return nil, err - } - return u, nil -} - -func (s *Store) GetUserByEmail(email string) (*domain.User, error) { - return s.GetUserByLogin(email) -} - -func (s *Store) GetUserByLogin(login string) (*domain.User, error) { - cleanLogin := strings.ToLower(strings.TrimSpace(login)) - if cleanLogin == "" { - return nil, errors.New("benutzername oder e-mail darf nicht leer sein") - } - row := s.db.QueryRow( - `SELECT `+userSelectCols+` FROM users - WHERE LOWER(email) = ? - OR (username != '' AND LOWER(username) = ?) - OR LOWER(display_name) = ? - OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?) - LIMIT 1`, - cleanLogin, cleanLogin, cleanLogin, cleanLogin, - ) - return scanUser(row) -} - -// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation -func (s *Store) EnsureDemoAccounts() error { - s.initMu.Lock() - defer s.initMu.Unlock() - - adminHash, err := auth.HashPassword("admin") - if err != nil { - return err - } - userHash, err := auth.HashPassword("user") - if err != nil { - return err - } - - // 1. Admin user (admin / admin) - adminCreated := false - adminUser, err := s.GetUserByLogin("admin") - if err != nil || adminUser == nil { - adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin") - if err != nil { - return fmt.Errorf("failed to create demo admin: %w", err) - } - adminCreated = true - } else { - _ = s.UpdateUserPassword(adminUser.ID, adminHash) - _, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID) - } - - // 2. Regular user (user / user) - normalUser, err := s.GetUserByLogin("user") - if err != nil || normalUser == nil { - normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member") - if err != nil { - return fmt.Errorf("failed to create demo user: %w", err) - } - } else { - _ = s.UpdateUserPassword(normalUser.ID, userHash) - _, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID) - } - - // 3. Sample data for the admin, partly shared with the demo user (only on first start). Both accounts - // share an organization so they can see each other in the default sharing mode. - if adminCreated { - if org, err := s.CreateOrganization("Demo"); err == nil { - _ = s.SetUserOrganization(adminUser.ID, org.ID) - _ = s.SetUserOrganization(normalUser.ID, org.ID) - } - _ = s.SeedStarterContent(adminUser) - s.seedDemoShares(adminUser, normalUser) - } - - return nil -} - -func (s *Store) GetUserByID(id string) (*domain.User, error) { - row := s.db.QueryRow( - `SELECT `+userSelectCols+` FROM users WHERE id = ?`, - id, - ) - return scanUser(row) -} - -func (s *Store) GetFirstUser() (*domain.User, error) { - row := s.db.QueryRow( - `SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC LIMIT 1`, - ) - return scanUser(row) -} - -func (s *Store) GetAllUsers() ([]domain.User, error) { - rows, err := s.db.Query( - `SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`, - ) - if err != nil { - return nil, err - } - users := make([]domain.User, 0) - for rows.Next() { - if u, err := scanUser(rows); err == nil { - users = append(users, *u) - } - } - _ = rows.Close() - return users, nil -} - -func (s *Store) UpdateUser( - userID string, - displayName, email, role *string, - isActive *bool, - discordWebhookURL *string, - notifyBeforeMinutes *int, -) (*domain.User, error) { - return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes, nil) -} - -func (s *Store) UpdateUserFull( - userID string, - displayName, email, role *string, - isActive *bool, - alarmChannels *[]string, - alarmEmail, discordWebhookURL, customWebhookURL *string, - notifyBeforeMinutes *int, - avatarURL *string, -) (*domain.User, error) { - u, err := s.GetUserByID(userID) - if err != nil { - return nil, err - } - if displayName != nil { - u.DisplayName = strings.TrimSpace(*displayName) - } - if email != nil { - u.Email = strings.ToLower(strings.TrimSpace(*email)) - } - if role != nil && (*role == "admin" || *role == "member") { - u.Role = *role - } - if isActive != nil { - u.IsActive = *isActive - } - if alarmChannels != nil { - u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ",")) - } - if alarmEmail != nil { - u.AlarmEmail = strings.TrimSpace(*alarmEmail) - } - if discordWebhookURL != nil { - u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL) - } - if customWebhookURL != nil { - u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL) - } - if notifyBeforeMinutes != nil { - u.NotifyBeforeMinutes = *notifyBeforeMinutes - } - if avatarURL != nil { - u.AvatarURL = strings.TrimSpace(*avatarURL) - } - if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) { - u.TokenVersion++ - } - activeInt := 0 - if u.IsActive { - activeInt = 1 - } - channelsStr := strings.Join(u.AlarmChannels, ",") - _, err = s.db.Exec( - `UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`, - u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID, - ) - if err != nil { - return nil, err - } - return u, nil -} - -func (s *Store) UpdateUserPassword(userID, passwordHash string) error { - _, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID) - return err -} - -func (s *Store) DeleteUser(userID string) error { - // Remove owned data properly first (other people's tasks in the user's lists stay theirs) - if err := s.PurgeUserData(userID); err != nil { - return err - } - _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID) - _, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID) - _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID) - // Shares pointing at the deleted account are no longer visible to anyone - s.pruneSharingNow() - return err -} - -// RevokeToken adds an individual JWT to the blacklist until its expiration date -func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error { - hash := auth.HashAPIKey(tokenStr) - _, err := s.db.Exec( - `INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`, - hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339), - ) - // Idempotently prune old expired tokens - _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339)) - return err -} - -// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted -func (s *Store) IsTokenRevoked(tokenStr string) bool { - hash := auth.HashAPIKey(tokenStr) - var count int - err := s.db.QueryRow( - `SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`, - hash, time.Now().UTC().Format(time.RFC3339), - ).Scan(&count) - return err == nil && count > 0 -} - -// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user -func (s *Store) RevokeAllUserTokens(userID string) error { - _, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID) - return err -} - -// --- THOUGHTS & TRIAGE --- - -func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) { - return s.CreateThoughtWithDetails(userID, content, priority, source, dueDate, "", "todo", "") -} - -func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) { - content = strings.TrimSpace(content) - dueDate = domain.NormalizeDue(dueDate) - if content == "" { - return nil, errors.New("content cannot be empty") - } - if priority == "" { - priority = "normal" - } - if source == "" { - source = "manual" - } - if kanbanStatus == "" { - kanbanStatus = "todo" - } - if err := s.ensureTaskListWritable(userID, taskListID); err != nil { - return nil, err - } - - tags := domain.ExtractTags(content) - if strings.EqualFold(source, "voice") { - hasDiktat := false - for _, tag := range tags { - if strings.EqualFold(tag, "diktat") { - hasDiktat = true - break - } - } - if !hasDiktat { - tags = append(tags, "diktat") - } - } - if strings.EqualFold(source, "webhook") { - hasWebhook := false - for _, tag := range tags { - if strings.EqualFold(tag, "webhook") { - hasWebhook = true - break - } - } - if !hasWebhook { - tags = append(tags, "webhook") - } - } - tagsJSON, _ := json.Marshal(tags) - - t := &domain.Thought{ - ID: domain.NewID("th"), - UserID: userID, - Content: content, - Tags: tags, - Priority: priority, - Source: source, - Status: domain.StatusInbox, - DueDate: dueDate, - TaskListID: taskListID, - KanbanStatus: kanbanStatus, - Notes: notes, - CreatedAt: domain.NowISO(), - } - - _, err := s.db.Exec( - `INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`, - t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, - ) - if err != nil { - return nil, err - } - t.Subtasks = []domain.SubTask{} - return t, nil -} - -func scanThought(rows interface { - Scan(dest ...any) error -}) (*domain.Thought, error) { - t := &domain.Thought{} - var tagsStr, subtasksStr, statusStr string - var isCompleted, alarmSent, sortOrder int - err := rows.Scan( - &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, - &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, - &t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, - &t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence, - ) - if err != nil { - return nil, err - } - t.SortOrder = sortOrder - t.Status = domain.ThoughtStatus(statusStr) - t.IsCompleted = isCompleted != 0 - t.AlarmSent = alarmSent != 0 - if t.KanbanStatus == "" { - if t.IsCompleted { - t.KanbanStatus = "done" - } else { - t.KanbanStatus = "todo" - } - } - t.Tags = []string{} - _ = json.Unmarshal([]byte(tagsStr), &t.Tags) - if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") { - parts := strings.SplitN(t.ConvertedRefID, ":", 2) - t.DocID = parts[0] - t.BlockID = parts[1] - } - t.Assignees = []string{} - t.Subtasks = []domain.SubTask{} - if subtasksStr != "" && subtasksStr != "null" { - _ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks) - } - return t, nil -} - -func (s *Store) accessibleTaskListIDsQuery() string { - return `SELECT id FROM task_lists - WHERE user_id = ? - OR id IN ( - SELECT task_list_id FROM task_list_shares - WHERE (target_type = 'user' AND target_id = ?) - OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - )` -} - -func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { - row := s.db.QueryRow( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence - FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, - thoughtID, userID, userID, userID, userID, - ) - t, err := scanThought(row) - if err != nil { - return nil, err - } - t.Assignees = s.getTaskAssignees(t.ID) - return t, nil -} - -func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) { - if limit <= 0 || limit > 500 { - limit = 200 - } - var rows *sql.Rows - var err error - - if statusFilter != "" && statusFilter != "all" { - rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence - FROM thoughts WHERE user_id = ? AND status = ? - ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`, - userID, statusFilter, limit, - ) - } else { - rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence - FROM thoughts WHERE user_id = ? - ORDER BY sort_order ASC, created_at DESC LIMIT ?`, - userID, limit, - ) - } - if err != nil { - return nil, err - } - - result := make([]domain.Thought, 0) - for rows.Next() { - t, err := scanThought(rows) - if err == nil { - result = append(result, *t) - } - } - _ = rows.Close() - return result, nil -} - -func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, error) { - var rows *sql.Rows - var err error - if taskListID != "" { - rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence - FROM thoughts - WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) - AND task_list_id = ? - AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) - ORDER BY is_completed ASC, sort_order ASC, created_at DESC`, - userID, userID, userID, userID, taskListID, - ) - } else { - rows, err = s.db.Query( - `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence - FROM thoughts - WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) - AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) - ORDER BY is_completed ASC, sort_order ASC, created_at DESC`, - userID, userID, userID, userID, - ) - } - if err != nil { - return nil, err - } - - result := make([]domain.Thought, 0) - for rows.Next() { - t, err := scanThought(rows) - if err == nil { - result = append(result, *t) - } - } - _ = rows.Close() - s.attachTaskAssignees(result) - return result, nil -} - -func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - var completed []string // tasks that move into "done" now (repeating ones spawn their successor) - for _, it := range items { - if it.KanbanStatus != nil && *it.KanbanStatus != "" { - isCompleted := 0 - if *it.KanbanStatus == "done" { - isCompleted = 1 - var was int - _ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was) - if was == 0 { - completed = append(completed, it.ID) - } - } - _, err = tx.Exec( - `UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?, - alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`, - it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID, - ) - } else { - _, err = tx.Exec( - `UPDATE thoughts SET sort_order = ? WHERE id = ?`, - it.SortOrder, it.ID, - ) - } - if err != nil { - return err - } - } - - if err := tx.Commit(); err != nil { - return err - } - for _, id := range completed { - s.spawnNextOccurrence(id) - } - return nil -} - -// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item -func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) { - thought, err := s.GetThoughtByID(userID, thoughtID) - if err != nil { - return nil, err - } - - now := domain.NowISO() - newStatus := domain.StatusKept - convertedType := thought.ConvertedType - convertedRefID := thought.ConvertedRefID - dueDate = domain.NormalizeDue(dueDate) - if dueDate == "" { - dueDate = thought.DueDate - } - if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil { - return nil, err - } - if taskListID == "" { - taskListID = thought.TaskListID - } else if err := s.ensureTaskListWritable(userID, taskListID); err != nil { - return nil, err - } - - switch action { - case "discard": - newStatus = domain.StatusDiscarded - case "restore": - newStatus = domain.StatusInbox - case "keep": - newStatus = domain.StatusKept - if convertTo == "task" { - newStatus = domain.StatusConverted - convertedType = "task" - } else if convertTo == "document" { - newStatus = domain.StatusConverted - convertedType = "document" - title := thought.Content - if len(title) > 72 { - title = title[:72] + "…" - } - blocks := make([]domain.DocumentBlock, 0) - - // 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen. - blocks = append(blocks, domain.DocumentBlock{ - ID: domain.NewID("blk"), - Type: "todo", - Content: thought.Content, - Checked: thought.IsCompleted, - Level: 0, - }) - - // 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität). - if strings.TrimSpace(thought.Notes) != "" { - blocks = append(blocks, domain.DocumentBlock{ - ID: domain.NewID("blk"), - Type: "bullet", - Content: strings.TrimSpace(thought.Notes), - Level: 1, - }) - } - if len(thought.Subtasks) > 0 { - for _, st := range thought.Subtasks { - blocks = append(blocks, domain.DocumentBlock{ - ID: domain.NewID("blk"), - Type: "todo", - Content: st.Title, - Checked: st.IsCompleted, - Level: 1, - }) - } - } - var metaItems []string - if thought.DueDate != "" { - metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate)) - } - if thought.Priority != "" && thought.Priority != "normal" { - metaItems = append(metaItems, "Priorität: "+thought.Priority) - } - if len(metaItems) > 0 { - blocks = append(blocks, domain.DocumentBlock{ - ID: domain.NewID("blk"), - Type: "bullet", - Content: strings.Join(metaItems, " • "), - Level: 1, - }) - } - if len(blocks) == 1 { - blocks = append(blocks, domain.DocumentBlock{ - ID: domain.NewID("blk"), - Type: "bullet", - Content: "", - Level: 0, - }) - } - doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false) - if err == nil && doc != nil { - convertedRefID = doc.ID - } - } else if convertTo == "list_item" && targetListID != "" { - newStatus = domain.StatusConverted - convertedType = "list_item" - item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID) - if err == nil && item != nil { - convertedRefID = item.ID - } - } - default: - return nil, fmt.Errorf("invalid triage action: %s", action) - } - - _, err = s.db.Exec( - `UPDATE thoughts - SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?, - alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END - WHERE id = ? AND user_id = ?`, - string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID, - ) - if err != nil { - return nil, err - } - if taskListID != thought.TaskListID { - s.PruneTaskAssignees() - } - - return s.GetThoughtByID(userID, thoughtID) -} - -func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask, tags *[]string) (*domain.Thought, error) { - thought, err := s.GetThoughtByID(userID, thoughtID) - if err != nil { - return nil, err - } - wasCompleted := thought.IsCompleted - oldDue := thought.DueDate - if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil { - return nil, err - } - if taskListID != nil && *taskListID != thought.TaskListID { - if err := s.ensureTaskListWritable(userID, *taskListID); err != nil { - return nil, err - } - } - - if content != nil { - thought.Content = strings.TrimSpace(*content) - thought.Tags = domain.ExtractTags(thought.Content) - } - if tags != nil { - thought.Tags = *tags - } - if isCompleted != nil { - thought.IsCompleted = *isCompleted - if *isCompleted { - thought.KanbanStatus = "done" - } else if thought.KanbanStatus == "done" { - thought.KanbanStatus = "todo" - } - } - if dueDate != nil { - thought.DueDate = domain.NormalizeDue(*dueDate) - } - if priority != nil { - p := strings.ToLower(strings.TrimSpace(*priority)) - if p == "" || p == "none" { - p = "normal" - } - thought.Priority = p - } - if taskListID != nil { - thought.TaskListID = *taskListID - } - if kanbanStatus != nil && *kanbanStatus != "" { - thought.KanbanStatus = *kanbanStatus - if isCompleted == nil { - if *kanbanStatus == "done" { - thought.IsCompleted = true - } else { - thought.IsCompleted = false - } - } - } - if notes != nil { - thought.Notes = *notes - } - if subtasks != nil { - thought.Subtasks = *subtasks - } - - tagsJSON, _ := json.Marshal(thought.Tags) - subtasksJSON, _ := json.Marshal(thought.Subtasks) - completedInt := 0 - if thought.IsCompleted { - completedInt = 1 - } - - _, err = s.db.Exec( - `UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`, - thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID, - ) - if err != nil { - return nil, err - } - // A new due date or a reopened task gets its alarm again - if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) { - _, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID) - thought.AlarmSent = false - } - if taskListID != nil { - s.PruneTaskAssignees() - thought.Assignees = s.getTaskAssignees(thoughtID) - } - if !wasCompleted && thought.IsCompleted { - s.spawnNextOccurrence(thoughtID) - } - return thought, nil -} - -func (s *Store) DeleteThought(userID, thoughtID string) error { - thought, err := s.GetThoughtByID(userID, thoughtID) - if err != nil { - return err - } - if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil { - return err - } - _, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID) - return err -} - -func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) { - res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - // --- DOCUMENTS (WORKSPACE - PURE DATABASE ARCHITECTURE) --- func stripFolderTags(tags []string) []string { @@ -1459,3772 +635,3 @@ func (s *Store) migrateWorkspaceToPureDB() { } } } - -func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.DocumentBlock { - if d, _, err := loadCollabDoc(s.db, docID); err == nil && len(d.Blocks) > 0 { - blocks := flatDocumentBlocks(docID, d) - if links := s.docTaskLinks(docID); len(links) > 0 { - for i := range blocks { - blocks[i].TaskID = links[blocks[i].ID] - } - } - return blocks - } - - // Very old notes only stored blocks_json; convert them once - blocks := make([]domain.DocumentBlock, 0) - if fallbackJSON != "" && fallbackJSON != "[]" { - _ = json.Unmarshal([]byte(fallbackJSON), &blocks) - if len(blocks) > 0 { - s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO()) - if d, _, err := loadCollabDoc(s.db, docID); err == nil { - return flatDocumentBlocks(docID, d) - } - } - } - return blocks -} - -// GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID. -func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) { - userSet := make(map[string]bool) - - var ownerID, sharedFolderID string - err := s.db.QueryRow(`SELECT user_id, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&ownerID, &sharedFolderID) - if err != nil { - return nil, err - } - if ownerID != "" { - userSet[ownerID] = true - } - - // Direct user shares - uRows, err := s.db.Query(`SELECT user_id FROM doc_shares WHERE doc_id = ?`, docID) - if err == nil { - for uRows.Next() { - var uid string - if err := uRows.Scan(&uid); err == nil && uid != "" { - userSet[uid] = true - } - } - _ = uRows.Close() - } - - // Team shares - tRows, err := s.db.Query(` - SELECT tm.user_id - FROM doc_team_shares dts - JOIN team_members tm ON tm.team_id = dts.team_id - WHERE dts.doc_id = ? - `, docID) - if err == nil { - for tRows.Next() { - var uid string - if err := tRows.Scan(&uid); err == nil && uid != "" { - userSet[uid] = true - } - } - _ = tRows.Close() - } - - // Shared folder access - if sharedFolderID != "" { - sfUsers, err := s.GetAuthorizedSharedFolderUsers(sharedFolderID) - if err == nil { - for _, uid := range sfUsers { - userSet[uid] = true - } - } - } - - users := make([]string, 0, len(userSet)) - for uid := range userSet { - users = append(users, uid) - } - return users, nil -} - -// GetAuthorizedSharedFolderUsers returns all user IDs who have access to a shared folder. -func (s *Store) GetAuthorizedSharedFolderUsers(folderID string) ([]string, error) { - userSet := make(map[string]bool) - - var ownerID string - if err := s.db.QueryRow(`SELECT owner_id FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&ownerID); err == nil && ownerID != "" { - userSet[ownerID] = true - } - - rows, err := s.db.Query(` - SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? - `, folderID) - if err == nil { - type shareTarget struct{ tType, tID string } - var targets []shareTarget - for rows.Next() { - var st shareTarget - if err := rows.Scan(&st.tType, &st.tID); err == nil { - targets = append(targets, st) - } - } - _ = rows.Close() - - for _, st := range targets { - if st.tType == "user" && st.tID != "" { - userSet[st.tID] = true - } else if st.tType == "team" && st.tID != "" { - mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, st.tID) - if err == nil { - for mRows.Next() { - var uid string - if err := mRows.Scan(&uid); err == nil && uid != "" { - userSet[uid] = true - } - } - _ = mRows.Close() - } - } - } - } - - users := make([]string, 0, len(userSet)) - for uid := range userSet { - users = append(users, uid) - } - return users, nil -} - -func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string, blocks []domain.DocumentBlock, tags []string, isPinned bool) (*domain.Document, error) { - if strings.TrimSpace(title) == "" { - title = "Unbenannte Notiz" - } - if icon == "" { - icon = "📄" - } - if blocks == nil { - blocks = []domain.DocumentBlock{ - {ID: domain.NewID("blk"), Type: "paragraph", Content: ""}, - } - } - tags = stripFolderTags(tags) - - var plainBuilder strings.Builder - for _, b := range blocks { - if b.Content != "" { - plainBuilder.WriteString(b.Content) - plainBuilder.WriteString("\n") - } - } - - blocksJSON, _ := json.Marshal(blocks) - tagsJSON, _ := json.Marshal(tags) - now := domain.NowISO() - pinnedInt := 0 - if isPinned { - pinnedInt = 1 - } - - cleanFolder := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) - if cleanFolder == "." { - cleanFolder = "" - } - - mounts, _, _ := s.getUserFolderMounts(userID) - var sharedFolderID, subFolderPath string - if fID, sub, ok := matchMountPath(cleanFolder, mounts); ok { - sharedFolderID = fID - subFolderPath = sub - } - - doc := &domain.Document{ - ID: domain.NewID("doc"), - UserID: userID, - SpaceID: spaceID, - Title: strings.TrimSpace(title), - Icon: icon, - Content: plainBuilder.String(), - Blocks: blocks, - Tags: tags, - FolderPath: cleanFolder, - IsPinned: isPinned, - SharedFolderID: sharedFolderID, - SubFolderPath: subFolderPath, - IsSharedFolder: sharedFolderID != "", - Permission: "owner", - IsOwner: true, - CreatedAt: now, - UpdatedAt: now, - } - - _, err := s.db.Exec( - `INSERT INTO documents (id, user_id, space_id, title, icon, content, blocks_json, tags, folder_path, is_pinned, is_archived, created_at, updated_at, shared_folder_id, sub_folder_path) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, - doc.ID, doc.UserID, doc.SpaceID, doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.FolderPath, pinnedInt, doc.CreatedAt, doc.UpdatedAt, doc.SharedFolderID, doc.SubFolderPath, - ) - if err != nil { - return nil, err - } - if isPinned { - _, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, doc.ID, doc.CreatedAt) - } - s.replaceDocBlocksInDB(doc.ID, userID, doc.Blocks, now) - _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) - return doc, nil -} - -// getUserFolderMounts returns map[folder_id]mount_path and map[folder_id]folder_name for all shared folders accessible to userID -func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[string]string, error) { - mounts := make(map[string]string) - names := make(map[string]string) - - rows, err := s.db.Query(` - SELECT f.id, f.name, COALESCE(m.mount_path, f.name) as mount_path - FROM workspace_shared_folders f - LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? - LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND ( - (s.target_type = 'user' AND s.target_id = ?) OR - (s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - WHERE f.owner_id = ? OR s.id IS NOT NULL - GROUP BY f.id - `, userID, userID, userID, userID) - if err != nil { - return mounts, names, err - } - defer rows.Close() - - for rows.Next() { - var fID, fName, mPath string - if err := rows.Scan(&fID, &fName, &mPath); err == nil { - names[fID] = fName - if mPath == "" { - mPath = fName - } - mounts[fID] = mPath - } - } - return mounts, names, nil -} - -// matchMountPath checks if folderPath falls within any mounted shared folder. -// Returns (sharedFolderID, relativeSubPath, isMatch). -func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) { - clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) - if clean == "." { - clean = "" - } - if clean == "" { - return "", "", false - } - - var bestFolderID string - var bestSubPath string - var longestMountLen int = -1 - - for fID, mPath := range mounts { - cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mPath))) - if cleanMount == "." { - cleanMount = "" - } - if cleanMount == "" { - continue - } - - if clean == cleanMount { - if len(cleanMount) > longestMountLen { - longestMountLen = len(cleanMount) - bestFolderID = fID - bestSubPath = "" - } - } else if strings.HasPrefix(clean, cleanMount+"/") { - sub := strings.TrimPrefix(clean, cleanMount+"/") - if len(cleanMount) > longestMountLen { - longestMountLen = len(cleanMount) - bestFolderID = fID - bestSubPath = sub - } - } - } - - if longestMountLen >= 0 { - return bestFolderID, bestSubPath, true - } - return "", "", false -} - -func resolveDocumentPath(userID string, doc *domain.Document, mounts map[string]string, names map[string]string, userFolders map[string]string) { - if doc.SharedFolderID != "" { - mountPath, ok := mounts[doc.SharedFolderID] - if !ok || mountPath == "" { - mountPath = names[doc.SharedFolderID] - if mountPath == "" { - mountPath = "Geteilter Ordner" - } - } - doc.IsSharedFolder = true - if doc.SubFolderPath != "" { - doc.FolderPath = mountPath + "/" + doc.SubFolderPath - } else { - doc.FolderPath = mountPath - } - } else { - if uf, ok := userFolders[doc.ID]; ok { - doc.FolderPath = uf - } else if !doc.IsOwner { - doc.FolderPath = "" - } - } -} - -func scanDocument(rows interface { - Scan(dest ...any) error -}) (*domain.Document, error) { - d := &domain.Document{} - var blocksStr, tagsStr string - var isPinned, isArchived int - err := rows.Scan( - &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, - &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, - &d.SharedFolderID, &d.SubFolderPath, - ) - if err != nil { - return nil, err - } - d.IsPinned = isPinned != 0 - d.IsArchived = isArchived != 0 - d.Blocks = []domain.DocumentBlock{} - d.Tags = []string{} - _ = json.Unmarshal([]byte(blocksStr), &d.Blocks) - _ = json.Unmarshal([]byte(tagsStr), &d.Tags) - d.Tags = stripFolderTags(d.Tags) - return d, nil -} - -func (s *Store) scanDocumentWithBlocks(rows interface { - Scan(dest ...any) error -}) (*domain.Document, error) { - d := &domain.Document{} - var blocksStr, tagsStr string - var isPinned, isArchived int - err := rows.Scan( - &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, - &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, - &d.SharedFolderID, &d.SubFolderPath, - ) - if err != nil { - return nil, err - } - d.IsPinned = isPinned != 0 - d.IsArchived = isArchived != 0 - d.Tags = []string{} - _ = json.Unmarshal([]byte(tagsStr), &d.Tags) - d.Tags = stripFolderTags(d.Tags) - d.Blocks = s.loadDocBlocks(d.ID, blocksStr) - d.MarkdownCache = domain.CompileBlocksToMarkdown(d.Blocks) - d.FolderID = d.FolderPath - return d, nil -} - -func (s *Store) GetDocuments(userID string) ([]domain.Document, error) { - mounts, names, _ := s.getUserFolderMounts(userID) - - userFolders := make(map[string]string) - ufRows, err := s.db.Query(`SELECT doc_id, folder_path FROM doc_user_folders WHERE user_id = ?`, userID) - if err == nil { - for ufRows.Next() { - var did, fp string - if err := ufRows.Scan(&did, &fp); err == nil { - userFolders[did] = fp - } - } - _ = ufRows.Close() - } - - // 1. Fetch user's own documents (with per-user is_pinned from doc_pins) - rows, err := s.db.Query( - `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, - CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, - d.is_archived, d.created_at, d.updated_at, - d.shared_folder_id, d.sub_folder_path - FROM documents d - LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? - WHERE d.user_id = ? AND d.is_archived = 0 - ORDER BY is_pinned DESC, d.updated_at DESC`, - userID, userID, - ) - if err != nil { - return nil, err - } - - type rawDocRow struct { - doc domain.Document - blocksStr string - } - var ownRaw []rawDocRow - for rows.Next() { - d := domain.Document{} - var blocksStr, tagsStr string - var isPinned, isArchived int - if err := rows.Scan( - &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, - &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, - &d.SharedFolderID, &d.SubFolderPath, - ); err == nil { - d.IsPinned = isPinned != 0 - d.IsArchived = isArchived != 0 - d.Tags = []string{} - _ = json.Unmarshal([]byte(tagsStr), &d.Tags) - d.Tags = stripFolderTags(d.Tags) - d.Permission = "owner" - d.IsOwner = true - resolveDocumentPath(userID, &d, mounts, names, userFolders) - ownRaw = append(ownRaw, rawDocRow{doc: d, blocksStr: blocksStr}) - } - } - _ = rows.Close() - - docs := make([]domain.Document, 0, len(ownRaw)) - for _, r := range ownRaw { - r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) - docs = append(docs, r.doc) - } - - // 2. Fetch documents shared with the user directly, via teams, or via shared folders - sharedRows, err := s.db.Query( - `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, - CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, - d.is_archived, d.created_at, d.updated_at, - d.shared_folder_id, d.sub_folder_path, - CASE - WHEN wsf.owner_id = ? THEN 'owner' - WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write' - ELSE 'read' - END AS permission, - u.display_name - FROM documents d - JOIN users u ON d.user_id = u.id - LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ? - LEFT JOIN ( - SELECT dts2.doc_id, dts2.permission - FROM doc_team_shares dts2 - JOIN team_members tm ON tm.team_id = dts2.team_id - WHERE tm.user_id = ? - ) dts ON d.id = dts.doc_id - LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id - LEFT JOIN ( - SELECT wfs2.folder_id, wfs2.permission - FROM workspace_folder_shares wfs2 - WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) - OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) wfs ON d.shared_folder_id = wfs.folder_id - LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? - WHERE d.is_archived = 0 AND d.user_id != ? AND ( - s.user_id IS NOT NULL OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL - ) - GROUP BY d.id - ORDER BY is_pinned DESC, d.updated_at DESC`, - userID, userID, userID, userID, userID, userID, userID, userID, - ) - if err == nil { - var sharedRaw []rawDocRow - for sharedRows.Next() { - d := domain.Document{} - var blocksStr, tagsStr string - var isPinned, isArchived int - var perm, ownerName string - if scanErr := sharedRows.Scan( - &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, - &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, - &d.SharedFolderID, &d.SubFolderPath, - &perm, &ownerName, - ); scanErr == nil { - d.IsPinned = isPinned != 0 - d.IsArchived = isArchived != 0 - d.Tags = []string{} - _ = json.Unmarshal([]byte(tagsStr), &d.Tags) - d.Tags = stripFolderTags(d.Tags) - d.Permission = perm - d.IsOwner = (perm == "owner") - d.OwnerName = ownerName - resolveDocumentPath(userID, &d, mounts, names, userFolders) - sharedRaw = append(sharedRaw, rawDocRow{doc: d, blocksStr: blocksStr}) - } - } - _ = sharedRows.Close() - - for _, r := range sharedRaw { - r.doc.Blocks = s.loadDocBlocks(r.doc.ID, r.blocksStr) - docs = append(docs, r.doc) - } - } - - // 3. Attach shares to owned documents - for i := range docs { - if docs[i].IsOwner { - if shares, err := s.GetDocShares(docs[i].ID); err == nil { - docs[i].SharedWith = shares - } - } - } - - return docs, nil -} - -func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) { - mounts, names, _ := s.getUserFolderMounts(userID) - userFolders := make(map[string]string) - var personalFp string - if err := s.db.QueryRow(`SELECT folder_path FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID).Scan(&personalFp); err == nil { - userFolders[docID] = personalFp - } - - // Try owned document first (with per-user is_pinned) - row := s.db.QueryRow( - `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, - CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, - d.is_archived, d.created_at, d.updated_at, - d.shared_folder_id, d.sub_folder_path - FROM documents d - LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? - WHERE d.id = ? AND (d.user_id = ? OR (d.is_archived = 1 AND d.archived_by = ?))`, - userID, docID, userID, userID, - ) - doc, err := s.scanDocumentWithBlocks(row) - if err == nil { - doc.Permission = "owner" - doc.IsOwner = doc.UserID == userID - if doc.IsArchived { - doc.Permission = "read" // read-only while in the trash - } - resolveDocumentPath(userID, doc, mounts, names, userFolders) - if shares, err := s.GetDocShares(doc.ID); err == nil { - doc.SharedWith = shares - } - return doc, nil - } - - // Try shared document (direct, via team, or via shared folder) - sharedRow := s.db.QueryRow( - `SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path, - CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned, - d.is_archived, d.created_at, d.updated_at, - d.shared_folder_id, d.sub_folder_path, - CASE - WHEN wsf.owner_id = ? THEN 'owner' - WHEN COALESCE(s.permission, '') = 'write' OR COALESCE(dts.permission, '') = 'write' OR COALESCE(wfs.permission, '') = 'write' THEN 'write' - ELSE 'read' - END AS permission, - u.display_name - FROM documents d - JOIN users u ON d.user_id = u.id - LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ? - LEFT JOIN ( - SELECT dts2.doc_id, dts2.permission - FROM doc_team_shares dts2 - JOIN team_members tm ON tm.team_id = dts2.team_id - WHERE tm.user_id = ? - ) dts ON d.id = dts.doc_id - LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id - LEFT JOIN ( - SELECT wfs2.folder_id, wfs2.permission - FROM workspace_folder_shares wfs2 - WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) - OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) wfs ON d.shared_folder_id = wfs.folder_id - LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ? - WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL) - LIMIT 1`, - userID, userID, userID, userID, userID, userID, docID, userID, userID, - ) - d := &domain.Document{} - var blocksStr, tagsStr string - var isPinned, isArchived int - var perm, ownerName string - err = sharedRow.Scan( - &d.ID, &d.UserID, &d.SpaceID, &d.Title, &d.Icon, &d.Content, - &blocksStr, &tagsStr, &d.FolderPath, &isPinned, &isArchived, &d.CreatedAt, &d.UpdatedAt, - &d.SharedFolderID, &d.SubFolderPath, - &perm, &ownerName, - ) - if err != nil { - return nil, err - } - d.IsPinned = isPinned != 0 - d.IsArchived = isArchived != 0 - d.Tags = []string{} - _ = json.Unmarshal([]byte(tagsStr), &d.Tags) - d.Tags = stripFolderTags(d.Tags) - d.Blocks = s.loadDocBlocks(d.ID, blocksStr) - d.Permission = perm - d.IsOwner = (perm == "owner") - d.OwnerName = ownerName - resolveDocumentPath(userID, d, mounts, names, userFolders) - if shares, err := s.GetDocShares(d.ID); err == nil { - d.SharedWith = shares - } - return d, nil -} - -func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, isPinned *bool) (*domain.Document, error) { - doc, err := s.GetDocumentByID(userID, docID) - if err != nil { - return nil, err - } - if doc.IsArchived { - return nil, ErrDocInTrash - } - - // Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is - // only changed through the operation log (POST /docs/{id}/sync). - changesContent := title != nil || icon != nil - if changesContent && !CanEditDoc(s.DocRole(userID, docID)) { - return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") - } - - if isPinned != nil { - doc.IsPinned = *isPinned - if *isPinned { - _, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO()) - if doc.IsOwner { - _, _ = s.db.Exec(`UPDATE documents SET is_pinned = 1 WHERE id = ?`, docID) - } - } else { - _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID) - if doc.IsOwner { - _, _ = s.db.Exec(`UPDATE documents SET is_pinned = 0 WHERE id = ?`, docID) - } - } - } - - if folderPath != nil { - movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath) - if moveErr != nil { - return nil, moveErr - } - doc.FolderPath = movedDoc.FolderPath - doc.SharedFolderID = movedDoc.SharedFolderID - doc.SubFolderPath = movedDoc.SubFolderPath - doc.IsSharedFolder = movedDoc.IsSharedFolder - } - - if !changesContent { - return doc, nil - } - if title != nil { - doc.Title = strings.TrimSpace(*title) - s.applyLegacyTitle(docID, userID, doc.Title) - } - if icon != nil { - doc.Icon = *icon - } - doc.UpdatedAt = domain.NowISO() - if _, err := s.db.Exec(`UPDATE documents SET icon = ?, updated_at = ? WHERE id = ?`, doc.Icon, doc.UpdatedAt, docID); err != nil { - return nil, err - } - return s.GetDocumentByID(userID, docID) -} - -func (s *Store) DeleteDocument(userID, docID string) error { - doc, err := s.GetDocumentByID(userID, docID) - if err != nil { - return err - } - if !doc.IsOwner { - // Collaborator removing their shared access - _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) - return s.DeleteDocShare(userID, docID, userID) - } - if doc.IsArchived { - return s.hardDeleteDocument(docID) - } - return s.trashDocument(userID, docID) -} - -func (s *Store) reindexAllDocBlockTags() error { - rows, err := s.db.Query(`SELECT id, user_id, blocks_json FROM documents WHERE is_archived = 0`) - if err != nil { - return err - } - defer rows.Close() - - type docInfo struct { - id, userID, blocksJSON string - } - var docs []docInfo - for rows.Next() { - var d docInfo - if err := rows.Scan(&d.id, &d.userID, &d.blocksJSON); err == nil { - docs = append(docs, d) - } - } - _ = rows.Close() - - for _, d := range docs { - blocks := s.loadDocBlocks(d.id, d.blocksJSON) - if len(blocks) > 0 { - _ = s.indexDocBlockTags(d.id, d.userID, blocks) - } - } - return nil -} - -func (s *Store) indexDocBlockTags(docID, _ string, blocks []domain.DocumentBlock) error { - _, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID) - if len(blocks) == 0 { - return nil - } - - stmt, err := s.db.Prepare(`INSERT OR IGNORE INTO block_tags (tag, document_id, block_id) VALUES (?, ?, ?)`) - if err != nil { - return err - } - defer stmt.Close() - - for _, b := range blocks { - if b.Content == "" { - continue - } - tags := domain.ExtractTags(b.Content) - for _, tag := range tags { - _, _ = stmt.Exec(tag, docID, b.ID) - } - } - return nil -} - -func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) { - cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#")) - if cleanTag == "" { - return "", errors.New("tag name required") - } - now := domain.NowISO() - _, err := s.db.Exec( - `INSERT INTO tag_pages (user_id, tag, content, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(user_id, tag) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`, - userID, cleanTag, content, now, - ) - if err != nil { - return "", err - } - return now, nil -} - -func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) { - rows, err := s.db.Query( - `SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count - FROM block_tags t - JOIN documents d ON t.document_id = d.id - LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ? - LEFT JOIN ( - SELECT dts2.doc_id - FROM doc_team_shares dts2 - JOIN team_members tm ON tm.team_id = dts2.team_id - WHERE tm.user_id = ? - ) dts ON d.id = dts.doc_id - LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id - LEFT JOIN ( - SELECT wfs2.folder_id - FROM workspace_folder_shares wfs2 - WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) - OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) wfs ON d.shared_folder_id = wfs.folder_id - WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL))) - AND d.is_archived = 0 - GROUP BY t.tag - ORDER BY count DESC, t.tag ASC`, - userID, userID, userID, userID, userID, userID, userID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - results := make([]domain.TagSummary, 0) - seen := make(map[string]bool) - for rows.Next() { - var ts domain.TagSummary - if err := rows.Scan(&ts.Tag, &ts.Count); err == nil { - results = append(results, ts) - seen[ts.Tag] = true - } - } - _ = rows.Close() - - // Also include tags that have a non-empty tag_pages note even if not currently used in a block - pageRows, err := s.db.Query( - `SELECT tag FROM tag_pages WHERE user_id = ? AND TRIM(content) != '' ORDER BY tag ASC`, - userID, - ) - if err == nil { - for pageRows.Next() { - var t string - if err := pageRows.Scan(&t); err == nil && t != "" && !seen[t] { - results = append(results, domain.TagSummary{Tag: t, Count: 0}) - seen[t] = true - } - } - _ = pageRows.Close() - } - - return results, nil -} - -func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) { - cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#")) - if cleanTag == "" { - return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil - } - - var noteContent, noteUpdatedAt string - _ = s.db.QueryRow( - `SELECT content, updated_at FROM tag_pages WHERE user_id = ? AND tag = ?`, - userID, cleanTag, - ).Scan(¬eContent, ¬eUpdatedAt) - - rows, err := s.db.Query( - `SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id, - COALESCE(u.display_name, '') as owner_name, - COALESCE(s.permission, dts.permission, wfs.permission, '') as shared_permission - FROM block_tags t - JOIN documents d ON t.document_id = d.id - LEFT JOIN users u ON d.user_id = u.id - LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ? - LEFT JOIN ( - SELECT dts2.doc_id, dts2.permission - FROM doc_team_shares dts2 - JOIN team_members tm ON tm.team_id = dts2.team_id - WHERE tm.user_id = ? - ) dts ON d.id = dts.doc_id - LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id - LEFT JOIN ( - SELECT wfs2.folder_id, wfs2.permission - FROM workspace_folder_shares wfs2 - WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) - OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) wfs ON d.shared_folder_id = wfs.folder_id - WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL))) - AND t.tag = ? - AND d.is_archived = 0 - ORDER BY d.updated_at DESC`, - userID, userID, userID, userID, userID, userID, userID, cleanTag, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - type rawMatch struct { - blockID string - docID string - title string - icon string - folderPath string - blocksJSON string - updatedAt string - docUserID string - ownerName string - sharedPermission string - } - - var rawMatches []rawMatch - for rows.Next() { - var rm rawMatch - if err := rows.Scan(&rm.blockID, &rm.docID, &rm.title, &rm.icon, &rm.folderPath, &rm.blocksJSON, &rm.updatedAt, &rm.docUserID, &rm.ownerName, &rm.sharedPermission); err == nil { - rawMatches = append(rawMatches, rm) - } - } - _ = rows.Close() - - // Group rawMatches by docID - docMap := make(map[string][]rawMatch) - var docOrder []string - for _, rm := range rawMatches { - if _, exists := docMap[rm.docID]; !exists { - docOrder = append(docOrder, rm.docID) - } - docMap[rm.docID] = append(docMap[rm.docID], rm) - } - - totalMatches := 0 - docGroups := make([]domain.TagDocumentGroup, 0, len(docOrder)) - - for _, docID := range docOrder { - matches := docMap[docID] - first := matches[0] - - blocks := s.loadDocBlocks(first.docID, first.blocksJSON) - - blockIdxMap := make(map[string]int, len(blocks)) - for i, b := range blocks { - blockIdxMap[b.ID] = i - } - - matchedBlocks := make([]domain.MatchedTagBlock, 0) - processedBlockIDs := make(map[string]bool) - - for _, m := range matches { - if processedBlockIDs[m.blockID] { - continue - } - idx, ok := blockIdxMap[m.blockID] - if !ok { - continue - } - rootBlock := blocks[idx] - processedBlockIDs[rootBlock.ID] = true - - children := make([]domain.DocumentBlock, 0) - rootLevel := rootBlock.Level - for i := idx + 1; i < len(blocks); i++ { - if blocks[i].Level > rootLevel { - children = append(children, blocks[i]) - processedBlockIDs[blocks[i].ID] = true - } else { - break - } - } - - matchedBlocks = append(matchedBlocks, domain.MatchedTagBlock{ - RootBlock: rootBlock, - Children: children, - }) - totalMatches++ - } - - if len(matchedBlocks) == 0 { - continue - } - - isOwner := first.docUserID == userID - permission := "read" - if isOwner { - permission = "owner" - } else if first.sharedPermission != "" { - permission = first.sharedPermission - } - - docGroups = append(docGroups, domain.TagDocumentGroup{ - DocID: first.docID, - Title: first.title, - Icon: first.icon, - FolderPath: first.folderPath, - UpdatedAt: first.updatedAt, - IsOwner: isOwner, - Permission: permission, - OwnerName: first.ownerName, - MatchedBlocks: matchedBlocks, - }) - } - - return &domain.TagDetails{ - Tag: cleanTag, - NoteContent: noteContent, - NoteUpdatedAt: noteUpdatedAt, - Documents: docGroups, - TotalMatches: totalMatches, - }, nil -} - -func (s *Store) GetFolders(userID string) ([]string, error) { - folderSet := make(map[string]bool) - - // 1. Collect from resolved documents for this user - docs, err := s.GetDocuments(userID) - if err == nil { - for _, doc := range docs { - if doc.FolderPath != "" { - addFolderSegments(doc.FolderPath, folderSet) - } - } - } - - // 2. Add mounted shared folders (so even empty shared folders appear in the tree) - mounts, _, err := s.getUserFolderMounts(userID) - if err == nil { - for sfID, mPath := range mounts { - if mPath != "" { - addFolderSegments(mPath, folderSet) - - // 2b. Add all subfolders created in this shared folder - sRows, err := s.db.Query(`SELECT sub_path FROM workspace_folders WHERE shared_folder_id = ?`, sfID) - if err == nil { - for sRows.Next() { - var sp string - if err := sRows.Scan(&sp); err == nil && sp != "" { - fullP := mPath + "/" + sp - addFolderSegments(fullP, folderSet) - } - } - _ = sRows.Close() - } - } - } - } - - // 3. Add personal empty folders created by this user - pRows, err := s.db.Query(`SELECT path FROM workspace_folders WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID) - if err == nil { - for pRows.Next() { - var p string - if err := pRows.Scan(&p); err == nil && p != "" { - addFolderSegments(p, folderSet) - } - } - _ = pRows.Close() - } - - result := make([]string, 0, len(folderSet)) - for f := range folderSet { - result = append(result, f) - } - sort.Strings(result) - return result, nil -} - -func addFolderSegments(fp string, set map[string]bool) { - clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(fp))) - if clean == "." || clean == "" { - return - } - parts := strings.Split(clean, "/") - var cur string - for _, p := range parts { - if p != "" && p != "." { - if cur == "" { - cur = p - } else { - cur = cur + "/" + p - } - set[cur] = true - } - } -} - -func (s *Store) CreateFolder(userID, folderPath string) (string, error) { - clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) - if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") { - return "", fmt.Errorf("ungültiger Ordnerpfad") - } - - mounts, _, err := s.getUserFolderMounts(userID) - var sfID, subPath string - if err == nil { - sfID, subPath, _ = matchMountPath(clean, mounts) - } - - now := time.Now().UTC().Format(time.RFC3339) - id := domain.NewID("wfld") - - if sfID != "" { - var existsID string - err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID) - if err == sql.ErrNoRows { - _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - id, userID, sfID, clean, subPath, now) - } - } else { - var existsID string - err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID) - if err == sql.ErrNoRows { - _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`, - id, userID, clean, now) - } - } - - return clean, nil -} - -func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { - clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath))) - if clean == "" || clean == "." || strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") { - return nil, fmt.Errorf("ungültiger Ordnerpfad") - } - - var deletedDocIDs []string - seenDocID := make(map[string]bool) - - mounts, _, err := s.getUserFolderMounts(userID) - if err == nil { - sfID, subPath, isMatch := matchMountPath(clean, mounts) - if isMatch && sfID != "" { - if subPath == "" { - // Root of a shared folder - shFolder, sfErr := s.GetSharedFolderByID(userID, sfID) - if sfErr == nil && !shFolder.IsOwner { - // Collaborator removing mount/share - _ = s.DeleteSharedFolder(userID, sfID) - return []string{}, nil - } - rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID) - if qErr == nil { - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil && !seenDocID[id] { - seenDocID[id] = true - deletedDocIDs = append(deletedDocIDs, id) - } - } - _ = rows.Close() - } - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID) - } else { - // Subfolder inside a shared folder - rows, qErr := s.db.Query( - `SELECT id FROM documents WHERE shared_folder_id = ? AND (sub_folder_path = ? OR sub_folder_path LIKE ?)`, - sfID, subPath, subPath+"/%", - ) - if qErr == nil { - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil && !seenDocID[id] { - seenDocID[id] = true - deletedDocIDs = append(deletedDocIDs, id) - } - } - _ = rows.Close() - } - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`, - sfID, subPath, subPath+"/%") - } - } - } - - // Find all personal documents owned by this user in this folder and subfolders - rows, err := s.db.Query( - `SELECT id FROM documents - WHERE user_id = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL) AND (folder_path = ? OR folder_path LIKE ?)`, - userID, clean, clean+"/%", - ) - if err != nil { - return nil, err - } - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil && !seenDocID[id] { - seenDocID[id] = true - deletedDocIDs = append(deletedDocIDs, id) - } - } - _ = rows.Close() - - // The notes of the folder go to the trash; restoring one creates its folder again - for _, id := range deletedDocIDs { - _ = s.trashDocument(userID, id) - } - - // Also clear any personal folder placements for shared notes in this folder - _, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%") - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") - - return deletedDocIDs, nil -} - -func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Document, error) { - doc, err := s.GetDocumentByID(userID, docID) - if err != nil { - return nil, err - } - if doc.IsArchived { - return nil, ErrDocInTrash - } - - cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath))) - if cleanTarget == "." { - cleanTarget = "" - } - - mounts, _, err := s.getUserFolderMounts(userID) - if err != nil { - return nil, err - } - - now := domain.NowISO() - - // Check if target folder falls within any mounted shared folder - targetFolderID, subPath, isTargetShared := matchMountPath(cleanTarget, mounts) - - if isTargetShared { - // Moving into / within a shared folder - shFolder, err := s.GetSharedFolderByID(userID, targetFolderID) - if err != nil { - return nil, err - } - if !shFolder.IsOwner { - return nil, fmt.Errorf("nur der Eigentümer dieses geteilten Ordners kann Dateien dorthin verschieben") - } - - // Update document to belong to targetSharedFolderID - _, err = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ?, updated_at = ? WHERE id = ?`, - targetFolderID, subPath, now, docID) - if err != nil { - return nil, err - } - - // Remove personal override if any - _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) - - doc.SharedFolderID = targetFolderID - doc.SubFolderPath = subPath - doc.IsSharedFolder = true - doc.FolderPath = cleanTarget - return doc, nil - } - - // Target is NOT inside a shared folder (personal / standalone) - if doc.SharedFolderID != "" { - // Moving OUT of a shared folder into a personal folder - if !doc.IsOwner { - return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben") - } - _, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`, - cleanTarget, now, docID) - if err != nil { - return nil, err - } - doc.SharedFolderID = "" - doc.SubFolderPath = "" - doc.IsSharedFolder = false - doc.FolderPath = cleanTarget - return doc, nil - } - - // Standalone note - if doc.IsOwner { - // Owner moves their own document -> updates documents.folder_path - _, err = s.db.Exec(`UPDATE documents SET folder_path = ?, updated_at = ? WHERE id = ?`, cleanTarget, now, docID) - if err != nil { - return nil, err - } - doc.FolderPath = cleanTarget - return doc, nil - } - - // Recipient moves an individually shared note -> updates doc_user_folders ONLY! - _, err = s.db.Exec(` - INSERT INTO doc_user_folders (user_id, doc_id, folder_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(user_id, doc_id) DO UPDATE SET folder_path = excluded.folder_path, updated_at = excluded.updated_at - `, userID, docID, cleanTarget, now, now) - if err != nil { - return nil, err - } - - doc.FolderPath = cleanTarget - return doc, nil -} - -// MoveFolder moves a folder (and all its documents/subfolders) to a new target parent path. -func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, []string, error) { - cleanSource := filepath.ToSlash(filepath.Clean(strings.TrimSpace(sourcePath))) - if cleanSource == "" || cleanSource == "." || strings.HasPrefix(cleanSource, "/") || strings.Contains(cleanSource, "..") { - return "", nil, fmt.Errorf("ungültiger Quell-Ordnerpfad") - } - cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetParentPath))) - if cleanTarget == "." { - cleanTarget = "" - } - if cleanTarget != "" && (cleanTarget == cleanSource || strings.HasPrefix(cleanTarget, cleanSource+"/")) { - return "", nil, fmt.Errorf("ein Ordner kann nicht in sich selbst verschoben werden") - } - - folderName := filepath.Base(cleanSource) - var newFolderPath string - if cleanTarget == "" { - newFolderPath = folderName - } else { - newFolderPath = cleanTarget + "/" + folderName - } - - if newFolderPath == cleanSource { - return cleanSource, []string{}, nil - } - - // 1. Check if sourcePath is the root mount of a shared folder - mounts, _, err := s.getUserFolderMounts(userID) - if err == nil { - for sfID, mPath := range mounts { - if mPath == cleanSource { - err := s.UpdateFolderMount(userID, sfID, newFolderPath) - if err != nil { - return "", nil, err - } - return newFolderPath, []string{}, nil - } - } - } - - // 2. Move all affected documents - docs, err := s.GetDocuments(userID) - if err != nil { - return "", nil, err - } - - var movedDocIDs []string - for _, doc := range docs { - if doc.FolderPath == cleanSource || strings.HasPrefix(doc.FolderPath, cleanSource+"/") { - var nextDocFolder string - if doc.FolderPath == cleanSource { - nextDocFolder = newFolderPath - } else { - suffix := strings.TrimPrefix(doc.FolderPath, cleanSource) - nextDocFolder = newFolderPath + suffix - } - _, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder) - if moveErr == nil { - movedDocIDs = append(movedDocIDs, doc.ID) - } - } - } - - // 3. Re-map workspace_folders entries - targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts) - sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts) - - wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`) - if qErr == nil { - type wfRecord struct { - id, uid, sfID, path, subPath string - } - var records []wfRecord - for wfRows.Next() { - var r wfRecord - if err := wfRows.Scan(&r.id, &r.uid, &r.sfID, &r.path, &r.subPath); err == nil { - records = append(records, r) - } - } - _ = wfRows.Close() - - for _, r := range records { - if isSourceSF && r.sfID == sourceSFID { - matches := false - var suffix string - if sourceSub == "" { - matches = true - suffix = "/" + r.subPath - } else if r.subPath == sourceSub { - matches = true - suffix = "" - } else if strings.HasPrefix(r.subPath, sourceSub+"/") { - matches = true - suffix = strings.TrimPrefix(r.subPath, sourceSub) - } - if matches { - var newSub string - if targetSub == "" { - newSub = strings.TrimPrefix(folderName+suffix, "/") - } else { - newSub = targetSub + "/" + folderName + suffix - } - if isTargetSF { - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id) - } else { - newP := newFolderPath + suffix - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id) - } - } - } else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") { - suffix := strings.TrimPrefix(r.path, cleanSource) - newP := newFolderPath + suffix - if isTargetSF { - var newSub string - if targetSub == "" { - newSub = strings.TrimPrefix(folderName+suffix, "/") - } else { - newSub = targetSub + "/" + folderName + suffix - } - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id) - } else { - _, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id) - } - } - } - } - - _, _ = s.CreateFolder(userID, newFolderPath) - - return newFolderPath, movedDocIDs, nil -} - -// syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID. -func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { - if ownerID == "" || folderID == "" || cleanMount == "" { - return - } - - // 1. Link all documents owned by ownerID in cleanMount to this shared folder - rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`, - ownerID, cleanMount, cleanMount+"/%") - if err == nil { - type docEntry struct{ id, fp string } - var entries []docEntry - for rows.Next() { - var de docEntry - if err := rows.Scan(&de.id, &de.fp); err == nil { - entries = append(entries, de) - } - } - _ = rows.Close() - - for _, de := range entries { - var subPath string - if de.fp == cleanMount { - subPath = "" - } else if strings.HasPrefix(de.fp, cleanMount+"/") { - subPath = strings.TrimPrefix(de.fp, cleanMount+"/") - } - _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`, - folderID, subPath, de.id) - } - } - - // 2. Link any workspace_folders created by ownerID in cleanMount - fRows, err := s.db.Query(`SELECT id, path FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, - ownerID, cleanMount, cleanMount+"/%") - if err == nil { - type wfEntry struct{ id, path string } - var wfEntries []wfEntry - for fRows.Next() { - var wfe wfEntry - if err := fRows.Scan(&wfe.id, &wfe.path); err == nil { - wfEntries = append(wfEntries, wfe) - } - } - _ = fRows.Close() - - for _, wfe := range wfEntries { - subP := "" - if strings.HasPrefix(wfe.path, cleanMount+"/") { - subP = strings.TrimPrefix(wfe.path, cleanMount+"/") - } - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, - folderID, subP, wfe.id) - } - } -} - -func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) { - cleanName := strings.TrimSpace(folderName) - if cleanName == "" { - return nil, fmt.Errorf("ordnername darf nicht leer sein") - } - cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(initialMountPath))) - if cleanMount == "." || cleanMount == "" { - cleanMount = cleanName - } - - // Check if already shared by this user at this mount path - var existingID string - err := s.db.QueryRow(` - SELECT f.id FROM workspace_shared_folders f - JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? - WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?) - `, userID, userID, cleanMount, cleanName).Scan(&existingID) - if err == nil && existingID != "" { - s.syncSharedFolderContents(userID, existingID, cleanMount) - return s.GetSharedFolderByID(userID, existingID) - } - - now := domain.NowISO() - folderID := domain.NewID("fld") - - _, err = s.db.Exec(` - INSERT INTO workspace_shared_folders (id, owner_id, name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `, folderID, userID, cleanName, now, now) - if err != nil { - return nil, err - } - - _, err = s.db.Exec(` - INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `, userID, folderID, cleanMount, now, now) - if err != nil { - return nil, err - } - - s.syncSharedFolderContents(userID, folderID, cleanMount) - - var ownerName string - _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName) - - return &domain.SharedFolder{ - ID: folderID, - OwnerID: userID, - OwnerName: ownerName, - Name: cleanName, - MountPath: cleanMount, - IsOwner: true, - Permission: "owner", - Shares: []domain.FolderShare{}, - CreatedAt: now, - UpdatedAt: now, - }, nil -} - -func (s *Store) GetSharedFolders(userID string) ([]domain.SharedFolder, error) { - rows, err := s.db.Query(` - SELECT f.id, f.owner_id, u.display_name, f.name, - COALESCE(m.mount_path, f.name) as mount_path, - CASE - WHEN f.owner_id = ? THEN 1 - ELSE 0 - END as is_owner, - CASE - WHEN f.owner_id = ? THEN 'owner' - WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write' - ELSE 'read' - END as permission, - f.created_at, f.updated_at - FROM workspace_shared_folders f - JOIN users u ON f.owner_id = u.id - LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? - LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND ( - (s.target_type = 'user' AND s.target_id = ?) OR - (s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - WHERE f.owner_id = ? OR s.id IS NOT NULL - GROUP BY f.id - ORDER BY f.created_at ASC - `, userID, userID, userID, userID, userID, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - folders := make([]domain.SharedFolder, 0) - for rows.Next() { - var f domain.SharedFolder - var isOwner int - if err := rows.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err == nil { - f.IsOwner = isOwner != 0 - if f.IsOwner { - if shares, err := s.GetFolderShares(f.ID); err == nil { - f.Shares = shares - } - } - folders = append(folders, f) - } - } - return folders, nil -} - -func (s *Store) GetSharedFolderByID(userID, folderID string) (*domain.SharedFolder, error) { - row := s.db.QueryRow(` - SELECT f.id, f.owner_id, u.display_name, f.name, - COALESCE(m.mount_path, f.name) as mount_path, - CASE - WHEN f.owner_id = ? THEN 1 - ELSE 0 - END as is_owner, - CASE - WHEN f.owner_id = ? THEN 'owner' - WHEN MAX(CASE WHEN s.permission = 'write' THEN 1 ELSE 0 END) = 1 THEN 'write' - ELSE 'read' - END as permission, - f.created_at, f.updated_at - FROM workspace_shared_folders f - JOIN users u ON f.owner_id = u.id - LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = ? - LEFT JOIN workspace_folder_shares s ON s.folder_id = f.id AND ( - (s.target_type = 'user' AND s.target_id = ?) OR - (s.target_type = 'team' AND s.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - WHERE f.id = ? AND (f.owner_id = ? OR s.id IS NOT NULL) - GROUP BY f.id - `, userID, userID, userID, userID, userID, folderID, userID) - - var f domain.SharedFolder - var isOwner int - if err := row.Scan(&f.ID, &f.OwnerID, &f.OwnerName, &f.Name, &f.MountPath, &isOwner, &f.Permission, &f.CreatedAt, &f.UpdatedAt); err != nil { - return nil, fmt.Errorf("geteilter Ordner nicht gefunden") - } - f.IsOwner = isOwner != 0 - if f.IsOwner { - if shares, err := s.GetFolderShares(f.ID); err == nil { - f.Shares = shares - } - } - return &f, nil -} - -func (s *Store) UpdateFolderMount(userID, folderID, mountPath string) error { - cleanMount := filepath.ToSlash(filepath.Clean(strings.TrimSpace(mountPath))) - if cleanMount == "." || cleanMount == "" { - return fmt.Errorf("ungültiger Mount-Pfad") - } - now := domain.NowISO() - _, err := s.db.Exec(` - INSERT INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(user_id, folder_id) DO UPDATE SET mount_path = excluded.mount_path, updated_at = excluded.updated_at - `, userID, folderID, cleanMount, now, now) - return err -} - -func (s *Store) GetFolderShares(folderID string) ([]domain.FolderShare, error) { - shares := make([]domain.FolderShare, 0) - rows, err := s.db.Query(` - SELECT s.id, s.folder_id, s.target_type, s.target_id, - CASE - WHEN s.target_type = 'user' THEN u.display_name - WHEN s.target_type = 'team' THEN t.name - ELSE '' - END as target_name, - COALESCE(u.email, '') as email, - COALESCE(u.avatar_url, '') as avatar_url, - s.permission, s.created_by, s.created_at - FROM workspace_folder_shares s - LEFT JOIN users u ON s.target_type = 'user' AND s.target_id = u.id - LEFT JOIN teams t ON s.target_type = 'team' AND s.target_id = t.id - WHERE s.folder_id = ? - ORDER BY s.created_at ASC - `, folderID) - if err != nil { - return shares, err - } - defer rows.Close() - - for rows.Next() { - var sh domain.FolderShare - if err := rows.Scan(&sh.ID, &sh.FolderID, &sh.TargetType, &sh.TargetID, &sh.TargetName, &sh.Email, &sh.AvatarURL, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { - shares = append(shares, sh) - } - } - return shares, nil -} - -func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIdentifier, permission string) (*domain.FolderShare, error) { - f, err := s.GetSharedFolderByID(currentUserID, folderID) - if err != nil { - return nil, err - } - if !f.IsOwner { - return nil, fmt.Errorf("nur der Eigentümer kann Ordner-Freigaben verwalten") - } - - cleanType := strings.TrimSpace(targetType) - cleanTarget := strings.TrimSpace(targetIdentifier) - if cleanType == "" { - if strings.HasPrefix(cleanTarget, "team_") { - cleanType = "team" - } else { - cleanType = "user" - } - } - - permission = normalizeSharePermission(permission) - - var targetID, targetName, email, avatarURL string - if cleanType == "team" { - err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) - if err != nil { - return nil, ErrTargetNotFound - } - } else { - var u *domain.User - if strings.HasPrefix(cleanTarget, "usr_") { - u, _ = s.GetUserByID(cleanTarget) - } - if u == nil { - u, _ = s.GetUserByLogin(cleanTarget) - } - if u == nil { - u, err = s.GetUserByID(cleanTarget) - } - if err != nil || u == nil { - if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil { - cleanType = "team" - } else { - return nil, ErrTargetNotFound - } - } else { - if u.ID == f.OwnerID { - return nil, fmt.Errorf("ordner gehört bereits diesem Benutzer") - } - targetID = u.ID - targetName = u.DisplayName - email = u.Email - avatarURL = u.AvatarURL - } - } - - if !s.CanShareWith(currentUserID, cleanType, targetID) { - return nil, ErrTargetNotFound - } - - now := domain.NowISO() - shareID := domain.NewID("fsh") - - _, err = s.db.Exec(` - INSERT INTO workspace_folder_shares (id, folder_id, target_type, target_id, permission, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(folder_id, target_type, target_id) DO UPDATE SET permission = excluded.permission - `, shareID, folderID, cleanType, targetID, permission, currentUserID, now) - if err != nil { - return nil, err - } - - var ownerMount string - _ = s.db.QueryRow(`SELECT COALESCE(m.mount_path, f.name) FROM workspace_shared_folders f LEFT JOIN workspace_folder_mounts m ON m.folder_id = f.id AND m.user_id = f.owner_id WHERE f.id = ?`, folderID).Scan(&ownerMount) - if ownerMount == "" { - ownerMount = f.Name - } - s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount) - - if cleanType == "user" { - _, _ = s.db.Exec(` - INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `, targetID, folderID, f.Name, now, now) - } else if cleanType == "team" { - mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID) - if err == nil { - var memberIDs []string - for mRows.Next() { - var uid string - if err := mRows.Scan(&uid); err == nil && uid != "" { - memberIDs = append(memberIDs, uid) - } - } - _ = mRows.Close() - for _, uid := range memberIDs { - _, _ = s.db.Exec(` - INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `, uid, folderID, f.Name, now, now) - } - } - } - - return &domain.FolderShare{ - ID: shareID, - FolderID: folderID, - TargetType: cleanType, - TargetID: targetID, - TargetName: targetName, - Email: email, - AvatarURL: avatarURL, - Permission: permission, - CreatedBy: currentUserID, - CreatedAt: now, - }, nil -} - -func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error { - f, err := s.GetSharedFolderByID(currentUserID, folderID) - if err != nil { - return err - } - cleanID := strings.TrimSpace(shareID) - - // Only the owner manages shares; everyone else can only leave the folder - if !f.IsOwner { - _, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`, - folderID, currentUserID, cleanID, cleanID) - _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID) - return err - } - - var targetType, targetID string - _ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, - folderID, cleanID, cleanID).Scan(&targetType, &targetID) - - _, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID) - if err != nil { - return err - } - if targetType == "user" && targetID != "" { - _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID) - } - return nil -} - -func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error { - f, err := s.GetSharedFolderByID(currentUserID, folderID) - if err != nil { - return err - } - if !f.IsOwner { - return s.DeleteFolderShare(currentUserID, folderID, currentUserID) - } - - _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID) - _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID) - _, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID) - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID) - _, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID) - return err -} - -// GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder. -func (s *Store) GetSharedFolderDocIDs(folderID string) ([]string, error) { - rows, err := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ? AND is_archived = 0`, folderID) - if err != nil { - return nil, err - } - defer rows.Close() - - var docIDs []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err == nil && id != "" { - docIDs = append(docIDs, id) - } - } - return docIDs, nil -} - -func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) { - shares := make([]domain.DocShare, 0) - - // 1. Direct user shares - uRows, err := s.db.Query( - `SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at - FROM doc_shares s - JOIN users u ON s.user_id = u.id - WHERE s.doc_id = ? - ORDER BY s.created_at ASC`, - docID, - ) - if err == nil { - for uRows.Next() { - var sh domain.DocShare - if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { - sh.TargetType = "user" - sh.TargetID = sh.UserID - sh.TargetName = sh.DisplayName - shares = append(shares, sh) - } - } - _ = uRows.Close() - } - - // 2. Team shares - tRows, err := s.db.Query( - `SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at - FROM doc_team_shares s - JOIN teams t ON s.team_id = t.id - WHERE s.doc_id = ? - ORDER BY s.created_at ASC`, - docID, - ) - if err == nil { - for tRows.Next() { - var sh domain.DocShare - var teamID, teamName string - if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil { - sh.TargetType = "team" - sh.TargetID = teamID - sh.TargetName = teamName - sh.DisplayName = teamName - shares = append(shares, sh) - } - } - _ = tRows.Close() - } - - return shares, nil -} - -func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) { - doc, err := s.GetDocumentByID(currentUserID, docID) - if err != nil { - return nil, err - } - if !doc.IsOwner { - return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") - } - - cleanTeamID := strings.TrimSpace(teamID) - var teamName string - err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName) - if err != nil || !s.CanShareWith(currentUserID, "team", cleanTeamID) { - return nil, ErrTargetNotFound - } - - permission = normalizeSharePermission(permission) - - now := domain.NowISO() - shareID := domain.NewID("share") - _, err = s.db.Exec( - `INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`, - shareID, docID, cleanTeamID, permission, now, currentUserID, - ) - if err != nil { - return nil, err - } - - return &domain.DocShare{ - ID: shareID, - DocID: docID, - TargetType: "team", - TargetID: cleanTeamID, - TargetName: teamName, - DisplayName: teamName, - Permission: permission, - CreatedBy: currentUserID, - CreatedAt: now, - }, nil -} - -// normalizeSharePermission maps share roles from the API to the stored values: "write" (editor) or "read" (viewer). -func normalizeSharePermission(p string) string { - switch strings.ToLower(strings.TrimSpace(p)) { - case "write", "editor": - return "write" - } - return "read" -} - -func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) { - doc, err := s.GetDocumentByID(currentUserID, docID) - if err != nil { - return nil, err - } - if doc.IsArchived { - return nil, ErrDocInTrash - } - if !doc.IsOwner { - return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") - } - - cleanTarget := strings.TrimSpace(targetUserIdentifier) - - // If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare - if strings.HasPrefix(cleanTarget, "team_") { - return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) - } - - permission = normalizeSharePermission(permission) - - // Resolve target user by ID, username, or Email - var targetUser *domain.User - if strings.HasPrefix(cleanTarget, "usr_") { - targetUser, _ = s.GetUserByID(cleanTarget) - } - if targetUser == nil { - targetUser, _ = s.GetUserByLogin(cleanTarget) - } - if targetUser == nil { - targetUser, err = s.GetUserByID(cleanTarget) - } - if err != nil || targetUser == nil { - // Check if it's a team ID that doesn't start with team_ - var tName string - if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil { - return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) - } - return nil, ErrTargetNotFound - } - - if targetUser.ID == doc.UserID { - return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer") - } - if !s.CanShareWith(currentUserID, "user", targetUser.ID) { - return nil, ErrTargetNotFound - } - - now := domain.NowISO() - shareID := domain.NewID("share") - _, err = s.db.Exec( - `INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(doc_id, user_id) DO UPDATE SET permission = excluded.permission`, - shareID, docID, targetUser.ID, permission, now, currentUserID, - ) - if err != nil { - return nil, err - } - - return &domain.DocShare{ - ID: shareID, - DocID: docID, - TargetType: "user", - TargetID: targetUser.ID, - TargetName: targetUser.DisplayName, - UserID: targetUser.ID, - UserEmail: targetUser.Email, - DisplayName: targetUser.DisplayName, - Permission: permission, - CreatedBy: currentUserID, - CreatedAt: now, - }, nil -} - -func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error { - doc, err := s.GetDocumentByID(currentUserID, docID) - if err != nil { - return err - } - // Only the owner manages shares; everyone else can only remove their own - if !doc.IsOwner && currentUserID != targetID { - return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe") - } - - cleanTarget := strings.TrimSpace(targetID) - // Try deleting team share first - res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget) - if err == nil { - if rows, _ := res.RowsAffected(); rows > 0 { - return nil - } - } - - // Fallback to user share - _, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget) - return err -} - -// GetCollaborators returns the active people the user can share with (everyone visible except the user). -func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) { - visible, err := s.GetDiscoverableUsers(currentUserID) - if err != nil { - return nil, err - } - users := make([]domain.User, 0, len(visible)) - for _, u := range visible { - if u.ID != currentUserID && u.IsActive { - users = append(users, u) - } - } - return users, nil -} - -// --- STANDALONE REALTIME LISTS & SHARING --- - -func (s *Store) CreateList(userID, title, icon, listType string) (*domain.SharedList, error) { - if strings.TrimSpace(title) == "" { - return nil, errors.New("list title required") - } - if icon == "" { - icon = "🛒" - } - if listType == "" { - listType = "shopping" - } - now := domain.NowISO() - lst := &domain.SharedList{ - ID: domain.NewID("lst"), - UserID: userID, - IsOwner: true, - Role: "editor", - Title: strings.TrimSpace(title), - Icon: icon, - ListType: listType, - Items: []domain.ListItem{}, - Shares: []domain.ListShare{}, - UpdatedAt: now, - CreatedAt: now, - } - - _, err := s.db.Exec( - `INSERT INTO lists (id, user_id, title, icon, list_type, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - lst.ID, lst.UserID, lst.Title, lst.Icon, lst.ListType, lst.UpdatedAt, lst.CreatedAt, - ) - if err != nil { - return nil, err - } - _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&lst.OwnerName) - return lst, nil -} - -func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role string) (*domain.ListShare, error) { - if targetType != "team" && targetType != "user" { - return nil, errors.New("ungültiger Freigabetyp") - } - targetID = strings.TrimSpace(targetID) - if targetID == "" { - return nil, errors.New("Ziel-ID erforderlich") - } - if role != "viewer" && role != "editor" { - role = "editor" - } - - if targetType == "user" { - var targetUser *domain.User - if strings.HasPrefix(targetID, "usr_") { - targetUser, _ = s.GetUserByID(targetID) - } - if targetUser == nil { - targetUser, _ = s.GetUserByLogin(targetID) - } - if targetUser == nil { - targetUser, _ = s.GetUserByEmail(targetID) - } - if targetUser == nil { - return nil, ErrTargetNotFound - } - targetID = targetUser.ID - } - - // Only the owner shares - var ownerID string - err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) - if err != nil { - return nil, errors.New("liste nicht gefunden") - } - if ownerID != currentUserID { - return nil, errors.New("nur der Eigentümer kann die Liste freigeben") - } - if !s.CanShareWith(currentUserID, targetType, targetID) { - return nil, ErrTargetNotFound - } - - now := domain.NowISO() - share := &domain.ListShare{ - ID: domain.NewID("lsh"), - ListID: listID, - TargetType: targetType, - TargetID: targetID, - Role: role, - CreatedBy: currentUserID, - CreatedAt: now, - } - - _, err = s.db.Exec( - `INSERT INTO list_shares (id, list_id, target_type, target_id, role, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(list_id, target_type, target_id) DO UPDATE SET role = excluded.role`, - share.ID, share.ListID, share.TargetType, share.TargetID, share.Role, share.CreatedBy, share.CreatedAt, - ) - if err != nil { - return nil, err - } - - _ = s.db.QueryRow(`SELECT id, created_at FROM list_shares WHERE list_id = ? AND target_type = ? AND target_id = ?`, - listID, targetType, targetID).Scan(&share.ID, &share.CreatedAt) - - if targetType == "team" { - _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName) - } else { - _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email) - } - return share, nil -} - -func (s *Store) RemoveListShare(currentUserID, listID, shareIDOrTargetID string) error { - var ownerID string - err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) - if err != nil { - return errors.New("liste nicht gefunden") - } - if ownerID != currentUserID { - return errors.New("nur der Eigentümer kann Freigaben entfernen") - } - - cleanTarget := strings.TrimSpace(shareIDOrTargetID) - _, err = s.db.Exec( - `DELETE FROM list_shares WHERE list_id = ? AND (id = ? OR target_id = ?)`, - listID, cleanTarget, cleanTarget, - ) - return err -} - -func (s *Store) GetListShares(listID string) ([]domain.ListShare, error) { - rows, err := s.db.Query( - `SELECT id, list_id, target_type, target_id, role, created_by, created_at - FROM list_shares WHERE list_id = ? ORDER BY created_at ASC`, - listID, - ) - if err != nil { - return []domain.ListShare{}, err - } - defer rows.Close() - - shares := make([]domain.ListShare, 0) - for rows.Next() { - var sh domain.ListShare - if err := rows.Scan(&sh.ID, &sh.ListID, &sh.TargetType, &sh.TargetID, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil { - if sh.TargetType == "team" { - _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName) - } else { - _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, sh.TargetID).Scan(&sh.TargetName, &sh.Email) - } - shares = append(shares, sh) - } - } - return shares, nil -} - -func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) { - rows, err := s.db.Query( - `SELECT DISTINCT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, - u.display_name, - CASE - WHEN l.user_id = ? THEN 'owner' - WHEN ls.role IS NOT NULL THEN ls.role - ELSE 'editor' - END as effective_role - FROM lists l - LEFT JOIN users u ON u.id = l.user_id - LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( - (ls.target_type = 'user' AND ls.target_id = ?) - OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - WHERE l.user_id = ? - OR ls.id IS NOT NULL - ORDER BY l.updated_at DESC`, - userID, userID, userID, userID, - ) - if err != nil { - return nil, err - } - - lists := make([]domain.SharedList, 0) - for rows.Next() { - var l domain.SharedList - var ownerName sql.NullString - var effectiveRole string - if err := rows.Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole); err == nil { - l.OwnerName = ownerName.String - l.IsOwner = (l.UserID == userID) - l.Role = effectiveRole - lists = append(lists, l) - } - } - _ = rows.Close() - - for i := range lists { - lists[i].Items, _ = s.GetListItems(lists[i].ID) - lists[i].Shares, _ = s.GetListShares(lists[i].ID) - lists[i].Members = s.getListMembers(lists[i].ID, lists[i].UserID) - } - return lists, nil -} - -func (s *Store) GetListByID(userID, listID string) (*domain.SharedList, error) { - var l domain.SharedList - var ownerName sql.NullString - var effectiveRole string - - var query string - var args []interface{} - if userID == "" { - query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, - u.display_name, 'owner' as effective_role - FROM lists l - LEFT JOIN users u ON u.id = l.user_id - WHERE l.id = ?` - args = []interface{}{listID} - } else { - query = `SELECT l.id, l.user_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at, - u.display_name, - CASE - WHEN l.user_id = ? THEN 'owner' - WHEN ls.role IS NOT NULL THEN ls.role - ELSE 'editor' - END as effective_role - FROM lists l - LEFT JOIN users u ON u.id = l.user_id - LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( - (ls.target_type = 'user' AND ls.target_id = ?) - OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - WHERE l.id = ? AND ( - l.user_id = ? - OR ls.id IS NOT NULL - )` - args = []interface{}{userID, userID, userID, listID, userID} - } - - err := s.db.QueryRow(query, args...).Scan(&l.ID, &l.UserID, &l.Title, &l.Icon, &l.ListType, &l.UpdatedAt, &l.CreatedAt, &ownerName, &effectiveRole) - if err != nil { - return nil, err - } - l.OwnerName = ownerName.String - l.IsOwner = (userID == "" || l.UserID == userID) - l.Role = effectiveRole - l.Items, _ = s.GetListItems(l.ID) - l.Shares, _ = s.GetListShares(l.ID) - l.Members = s.getListMembers(l.ID, l.UserID) - return &l, nil -} - -// getListMembers returns the owner and everyone a list is shared with (directly or via a team). -func (s *Store) getListMembers(listID, ownerID string) []domain.TaskListMember { - members := make([]domain.TaskListMember, 0) - rows, err := s.db.Query( - `SELECT id, display_name, email, avatar_url FROM users WHERE id IN ( - SELECT user_id FROM lists WHERE id = ? - UNION SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'user' - UNION SELECT user_id FROM team_members WHERE team_id IN (SELECT target_id FROM list_shares WHERE list_id = ? AND target_type = 'team') - ) ORDER BY display_name`, - listID, listID, listID, - ) - if err != nil { - return members - } - defer rows.Close() - for rows.Next() { - var m domain.TaskListMember - if rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL) == nil { - m.Role = "member" - if m.UserID == ownerID { - m.Role = "owner" - } - members = append(members, m) - } - } - return members -} - -func (s *Store) DeleteList(userID, listID string) error { - var ownerID string - err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID) - if err != nil { - return errors.New("liste nicht gefunden") - } - if ownerID != userID { - return errors.New("nur der Eigentümer kann die Liste löschen") - } - _, err = s.db.Exec(`DELETE FROM lists WHERE id = ?`, listID) - return err -} - -func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) { - rows, err := s.db.Query( - `SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name, - li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at - FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != '' - WHERE li.list_id = ? - ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`, - listID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - items := make([]domain.ListItem, 0) - for rows.Next() { - var it domain.ListItem - var doneInt int - if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt); err == nil { - it.IsCompleted = doneInt != 0 - items = append(items, it) - } - } - return items, nil -} - -func (s *Store) CreateListItem(listID, content, category, quantity string, sortOrder int, createdBy string) (*domain.ListItem, error) { - content = strings.TrimSpace(content) - if content == "" { - return nil, errors.New("item content cannot be empty") - } - - category = strings.TrimSpace(category) - // Auto-extract hashtag if category is empty - if category == "" && strings.Contains(content, "#") { - re := regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`) - if m := re.FindStringSubmatch(content); len(m) > 1 { - category = m[1] - cleaned := strings.TrimSpace(re.ReplaceAllString(content, "")) - if cleaned != "" { - content = cleaned - } - } - } else if category != "" { - category = strings.TrimPrefix(category, "#") - } - - now := domain.NowISO() - it := &domain.ListItem{ - ID: domain.NewID("itm"), - ListID: listID, - Content: content, - Category: category, - Quantity: strings.TrimSpace(quantity), - IsCompleted: false, - SortOrder: sortOrder, - CreatedBy: createdBy, - CreatedAt: now, - } - if createdBy != "" { - _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, createdBy).Scan(&it.CreatedByName) - } - - _, err := s.db.Exec( - `INSERT INTO list_items (id, list_id, content, category, quantity, is_completed, sort_order, created_at, created_by) - VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`, - it.ID, it.ListID, it.Content, it.Category, it.Quantity, it.SortOrder, it.CreatedAt, it.CreatedBy, - ) - if err != nil { - return nil, err - } - - _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, listID) - return it, nil -} - -func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName string) (*domain.ListItem, error) { - now := domain.NowISO() - doneInt := 0 - completedAt := "" - completedBy := "" - completedByName := "" - if isCompleted { - doneInt = 1 - completedAt = now - completedBy = userID - completedByName = userName - } - - _, err := s.db.Exec( - `UPDATE list_items SET is_completed = ?, completed_by = ?, completed_by_name = ?, completed_at = ? WHERE id = ?`, - doneInt, completedBy, completedByName, completedAt, itemID, - ) - if err != nil { - return nil, err - } - - var it domain.ListItem - var dInt int - err = s.db.QueryRow( - `SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at - FROM list_items WHERE id = ?`, - itemID, - ).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt) - if err != nil { - return nil, err - } - it.IsCompleted = dInt != 0 - _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID) - return &it, nil -} - -func (s *Store) UpdateListItem(itemID string, content *string, category *string, quantity *string) (*domain.ListItem, error) { - now := domain.NowISO() - var it domain.ListItem - var dInt int - err := s.db.QueryRow( - `SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at - FROM list_items WHERE id = ?`, - itemID, - ).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt) - if err != nil { - return nil, err - } - it.IsCompleted = dInt != 0 - - if content != nil { - it.Content = *content - } - if category != nil { - it.Category = *category - } - if quantity != nil { - it.Quantity = *quantity - } - - _, err = s.db.Exec(`UPDATE list_items SET content = ?, category = ?, quantity = ? WHERE id = ?`, it.Content, it.Category, it.Quantity, itemID) - if err != nil { - return nil, err - } - _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID) - return &it, nil -} - -func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) { - // Linking changes the block (its tick follows the task), so edit rights are needed - if !CanEditDoc(s.DocRole(userID, docID)) { - return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") - } - t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "") - if err != nil { - return nil, err - } - t.ConvertedType = "document_block" - t.ConvertedRefID = docID + ":" + blockID - t.DocID = docID - t.BlockID = blockID - t.IsCompleted = checked - t.Status = domain.StatusKept - doneInt := 0 - if checked { - doneInt = 1 - t.KanbanStatus = "done" - } - _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`, - t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID) - - s.SetBlockChecked(userID, docID, blockID, checked) - return t, nil -} - -// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened). -// Returns the note ID if the block changed, so the caller can notify its viewers. -func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string { - t, err := s.GetThoughtByID(userID, taskID) - if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") { - return "" - } - docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":") - if s.SetBlockChecked(userID, docID, blockID, isCompleted) { - return docID - } - return "" -} - -// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's -// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change. -func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) { - if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil { - return - } - _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID) -} - -func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) { - if taskID == "" { - return - } - doneInt := 0 - kanban := "todo" - if checked { - doneInt = 1 - kanban = "done" - } - res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END - WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt) - if checked && res != nil { - if n, _ := res.RowsAffected(); n > 0 { - s.spawnNextOccurrence(taskID) - } - } -} - -func (s *Store) DeleteListItem(itemID string) (string, error) { - var listID string - _ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID) - _, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, itemID) - if listID != "" { - _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID) - } - return listID, err -} - -func (s *Store) ClearCompletedListItems(listID string) (int64, error) { - res, err := s.db.Exec(`DELETE FROM list_items WHERE list_id = ? AND is_completed = 1`, listID) - if err != nil { - return 0, err - } - _, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID) - return res.RowsAffected() -} - -func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderItem) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - for _, it := range items { - if it.Category != nil && it.Content != nil { - _, err = tx.Exec( - `UPDATE list_items SET sort_order = ?, category = ?, content = ? WHERE id = ? AND list_id = ?`, - it.SortOrder, *it.Category, *it.Content, it.ID, listID, - ) - } else if it.Category != nil { - _, err = tx.Exec( - `UPDATE list_items SET sort_order = ?, category = ? WHERE id = ? AND list_id = ?`, - it.SortOrder, *it.Category, it.ID, listID, - ) - } else if it.Content != nil { - _, err = tx.Exec( - `UPDATE list_items SET sort_order = ?, content = ? WHERE id = ? AND list_id = ?`, - it.SortOrder, *it.Content, it.ID, listID, - ) - } else { - _, err = tx.Exec( - `UPDATE list_items SET sort_order = ? WHERE id = ? AND list_id = ?`, - it.SortOrder, it.ID, listID, - ) - } - if err != nil { - return err - } - } - - _, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID) - return tx.Commit() -} - -// --- API KEYS & WEBHOOKS --- - -func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) { - if strings.TrimSpace(name) == "" { - name = "Smarthome / Webhook Key" - } - rawKey, prefix, hash := auth.GenerateAPIKey() - k := &domain.APIKey{ - ID: domain.NewID("key"), - UserID: userID, - Name: strings.TrimSpace(name), - KeyPrefix: prefix, - KeyHash: hash, - RawKey: rawKey, - Scopes: "ingest,read", - CreatedAt: domain.NowISO(), - } - - _, err := s.db.Exec( - `INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - k.ID, k.UserID, k.Name, k.KeyPrefix, k.KeyHash, k.Scopes, k.CreatedAt, - ) - if err != nil { - return nil, err - } - return k, nil -} - -func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) { - rows, err := s.db.Query( - `SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at - FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, - userID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - keys := make([]domain.APIKey, 0) - for rows.Next() { - var k domain.APIKey - if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil { - keys = append(keys, k) - } - } - return keys, nil -} - -func (s *Store) DeleteAPIKey(userID, keyID string) error { - _, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID) - return err -} - -func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) { - hash := auth.HashAPIKey(strings.TrimSpace(rawKey)) - var keyID, userID string - err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID) - if err != nil { - return nil, errors.New("invalid API key") - } - _, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID) - return s.GetUserByID(userID) -} - -// --- STATS, SEARCH & EXPORT --- - -func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) { - st := &domain.DashboardStats{} - _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount) - // Same tasks as GetTasks without a list ("Alle Aufgaben"): own ones plus those in shared task lists - taskScope := `(user_id = ? OR task_list_id IN (` + s.accessibleTaskListIDsQuery() + `)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))` - _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 0`, userID, userID, userID, userID).Scan(&st.TasksOpenCount) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 1`, userID, userID, userID, userID).Scan(&st.TasksDoneCount) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount) - _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount) - _ = s.db.QueryRow(` - SELECT COUNT(DISTINCT l.id) - FROM lists l - LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( - (ls.target_type = 'user' AND ls.target_id = ?) - OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - WHERE l.user_id = ? OR ls.id IS NOT NULL`, - userID, userID, userID).Scan(&st.ListsCount) - - // Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) - docRows, err := s.db.Query(` - SELECT blocks_json FROM documents - WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?)) - AND is_archived = 0`, - userID, userID, - ) - if err == nil { - for docRows.Next() { - var bj string - if err := docRows.Scan(&bj); err == nil && bj != "" { - var blocks []domain.DocumentBlock - if err := json.Unmarshal([]byte(bj), &blocks); err == nil { - for _, b := range blocks { - if b.Type == "todo" && !b.Checked { - st.DocsTodosOpenCount++ - } - } - } - } - } - _ = docRows.Close() - } - - return st, nil -} - -func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) { - res, err := s.SearchGlobal(userID, query) - if err != nil { - return nil, nil, err - } - return res.Thoughts, res.Documents, nil -} - -func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { - user, err := s.GetUserByID(userID) - if err != nil { - return nil, err - } - thoughts, _ := s.GetThoughts(userID, "all", 5000) - docs, _ := s.GetDocuments(userID) - lists, _ := s.GetAllListsForUser(userID) - taskLists, _ := s.GetTaskLists(userID) - - return &domain.FullExport{ - ExportedAt: domain.NowISO(), - Version: "1.0.0", - User: *user, - Thoughts: thoughts, - Documents: docs, - Lists: lists, - TaskLists: taskLists, - }, nil -} - -// --- TASK LISTS --- - -func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string, columns ...[]domain.KanbanColumn) (*domain.TaskList, error) { - if strings.TrimSpace(title) == "" { - title = "Meine Aufgaben" - } - if icon == "" { - icon = "📋" - } - if color == "" { - color = "#2563eb" - } - cols := []domain.KanbanColumn{ - {ID: "todo", Name: "Zu erledigen"}, - {ID: "in_progress", Name: "In Bearbeitung", Color: "accent"}, - {ID: "waiting", Name: "Wartet auf andere", Color: "amber"}, - {ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"}, - } - if len(columns) > 0 && len(columns[0]) > 0 { - cols = columns[0] - } - colsJSON, _ := json.Marshal(cols) - now := domain.NowISO() - tl := &domain.TaskList{ - ID: domain.NewID("tkl"), - UserID: userID, - SpaceID: spaceID, - Title: strings.TrimSpace(title), - Icon: icon, - Color: color, - Columns: cols, - CreatedAt: now, - UpdatedAt: now, - IsOwner: true, - } - _, err := s.db.Exec( - `INSERT INTO task_lists (id, user_id, space_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt, - ) - if err != nil { - return nil, err - } - if teamID != "" && s.CanShareWith(userID, "team", teamID) { - _, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID) - } - return s.GetTaskListByID(userID, tl.ID) -} - -// ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks. -var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)") - -// ErrAssigneeNoAccess is returned when a task should be assigned to someone who cannot see its list. -var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff auf die Liste zugewiesen werden") - -// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user. -func (s *Store) GetTaskListPermission(userID, listID string) string { - var ownerID string - if err := s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&ownerID); err != nil { - return "" - } - if ownerID == userID { - return "owner" - } - rows, err := s.db.Query( - `SELECT permission FROM task_list_shares - WHERE task_list_id = ? AND ( - (target_type = 'user' AND target_id = ?) - OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - )`, - listID, userID, userID, - ) - perm := "" - if err == nil { - for rows.Next() { - var p string - if rows.Scan(&p) == nil { - if p == "viewer" { - if perm == "" { - perm = "viewer" - } - } else { - perm = "editor" - } - } - } - _ = rows.Close() - } - return perm -} - -func (s *Store) CanEditTaskList(userID, listID string) bool { - p := s.GetTaskListPermission(userID, listID) - return p == "owner" || p == "editor" -} - -// ensureTaskListWritable returns ErrTaskListReadOnly if the list is set and the user may not modify it. -func (s *Store) ensureTaskListWritable(userID, listID string) error { - if listID == "" || s.CanEditTaskList(userID, listID) { - return nil - } - return ErrTaskListReadOnly -} - -func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, createdBy string) (*domain.TaskListShare, error) { - if targetType != "team" && targetType != "user" { - return nil, errors.New("ungültiger Freigabetyp") - } - targetID = strings.TrimSpace(targetID) - if targetID == "" { - return nil, errors.New("Ziel-ID erforderlich") - } - if permission != "viewer" { - permission = "editor" - } - if !s.CanShareWith(createdBy, targetType, targetID) { - return nil, ErrTargetNotFound - } - now := domain.NowISO() - share := &domain.TaskListShare{ - ID: domain.NewID("tls"), - TaskListID: taskListID, - TargetType: targetType, - TargetID: targetID, - Permission: permission, - CreatedAt: now, - } - _, err := s.db.Exec( - `INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, permission, created_at, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at, permission = excluded.permission`, - share.ID, share.TaskListID, share.TargetType, share.TargetID, share.Permission, share.CreatedAt, createdBy, - ) - if err != nil { - return nil, err - } - _ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`, - taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt) - - if targetType == "team" { - _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName) - } else { - _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email) - } - return share, nil -} - -func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error { - _, err := s.db.Exec( - `DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, - taskListID, shareIDOrTargetID, shareIDOrTargetID, - ) - s.PruneTaskAssignees() - return err -} - -func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) { - rows, err := s.db.Query( - `SELECT id, task_list_id, target_type, target_id, permission, created_at - FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`, - taskListID, - ) - if err != nil { - return []domain.TaskListShare{}, err - } - defer rows.Close() - - shares := make([]domain.TaskListShare, 0) - for rows.Next() { - var sh domain.TaskListShare - if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.Permission, &sh.CreatedAt); err == nil { - shares = append(shares, sh) - } - } - for i := range shares { - if shares[i].TargetType == "team" { - _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName) - } else { - _ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email, &shares[i].AvatarURL) - } - } - return shares, nil -} - -// queryUserIDs runs a query returning a single user_id column and collects the distinct IDs. -func (s *Store) queryUserIDs(query string, args ...interface{}) []string { - rows, err := s.db.Query(query, args...) - if err != nil { - return []string{} - } - defer rows.Close() - ids := make([]string, 0) - seen := make(map[string]bool) - for rows.Next() { - var id string - if rows.Scan(&id) == nil && id != "" && !seen[id] { - seen[id] = true - ids = append(ids, id) - } - } - return ids -} - -// taskListAccessSQL selects every user ID with access to a task list: owner, direct shares and members of -// shared groups. %[1]s is the list ID expression ("?" or a correlated column). -const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s - UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user' - UNION SELECT tm.user_id FROM team_members tm - WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')` - -func (s *Store) getTaskAssignees(taskID string) []string { - ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID) - return ids -} - -// attachTaskAssignees fills Assignees for a batch of tasks with a single query. -func (s *Store) attachTaskAssignees(tasks []domain.Thought) { - if len(tasks) == 0 { - return - } - idx := make(map[string]int, len(tasks)) - placeholders := make([]string, 0, len(tasks)) - args := make([]interface{}, 0, len(tasks)) - for i := range tasks { - idx[tasks[i].ID] = i - placeholders = append(placeholders, "?") - args = append(args, tasks[i].ID) - } - rows, err := s.db.Query( - `SELECT task_id, user_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY created_at ASC`, - args..., - ) - if err != nil { - return - } - defer rows.Close() - for rows.Next() { - var taskID, uid string - if rows.Scan(&taskID, &uid) == nil { - if i, ok := idx[taskID]; ok { - tasks[i].Assignees = append(tasks[i].Assignees, uid) - } - } - } -} - -// SetTaskAssignees replaces the assignees of a task. Only editors of the task's list may assign, and only -// people who can see the list are accepted. -func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]string, error) { - thought, err := s.GetThoughtByID(userID, taskID) - if err != nil { - return nil, err - } - if thought.TaskListID == "" { - if len(assignees) > 0 { - return nil, ErrAssigneeNoAccess - } - return []string{}, nil - } - if !s.CanEditTaskList(userID, thought.TaskListID) { - return nil, ErrTaskListReadOnly - } - allowed := make(map[string]bool) - for _, id := range s.GetTaskListUserIDs(thought.TaskListID) { - allowed[id] = true - } - clean := make([]string, 0, len(assignees)) - seen := make(map[string]bool) - for _, id := range assignees { - id = strings.TrimSpace(id) - if id == "" || seen[id] { - continue - } - if !allowed[id] { - return nil, ErrAssigneeNoAccess - } - seen[id] = true - clean = append(clean, id) - } - - tx, err := s.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - keep := make([]interface{}, 0, len(clean)+1) - keep = append(keep, taskID) - ph := make([]string, 0, len(clean)) - for _, id := range clean { - keep = append(keep, id) - ph = append(ph, "?") - } - delQuery := `DELETE FROM task_assignees WHERE task_id = ?` - if len(ph) > 0 { - delQuery += ` AND user_id NOT IN (` + strings.Join(ph, ",") + `)` - } - if _, err := tx.Exec(delQuery, keep...); err != nil { - return nil, err - } - now := domain.NowISO() - for _, id := range clean { - if _, err := tx.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, id, now); err != nil { - return nil, err - } - } - if err := tx.Commit(); err != nil { - return nil, err - } - return s.getTaskAssignees(taskID), nil -} - -// PruneTaskAssignees drops every assignment whose user can no longer see the task's list (share removed, -// left the team, team deleted, task moved to another list or out of any list). Called after each change -// that can take access away, so assignments never outlive permissions. -func (s *Store) PruneTaskAssignees() { - _, _ = s.db.Exec( - `DELETE FROM task_assignees WHERE rowid IN ( - SELECT ta.rowid FROM task_assignees ta - LEFT JOIN thoughts t ON t.id = ta.task_id - WHERE t.id IS NULL OR t.task_list_id = '' - OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_id") + `) - )`, - ) -} - -// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares and members of shared teams. -func (s *Store) GetTaskListUserIDs(listID string) []string { - if listID == "" { - return []string{} - } - return s.queryUserIDs(fmt.Sprintf(taskListAccessSQL, "?"), listID, listID, listID) -} - -// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with. -func (s *Store) GetSharedFolderUserIDs(folderID string) []string { - if folderID == "" { - return []string{} - } - return s.queryUserIDs( - `SELECT owner_id FROM workspace_shared_folders WHERE id = ? - UNION SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' - UNION SELECT tm.user_id FROM team_members tm - WHERE tm.team_id IN (SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'team')`, - folderID, folderID, folderID, - ) -} - -func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember { - memberMap := make(map[string]domain.TaskListMember) - - // 1. Owner - var ownerName, ownerEmail, ownerAvatar string - _ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar) - memberMap[tl.UserID] = domain.TaskListMember{ - UserID: tl.UserID, - DisplayName: ownerName, - Email: ownerEmail, - AvatarURL: ownerAvatar, - Role: "owner", - } - - // 2. From task_list_shares - for _, sh := range tl.Shares { - if sh.TargetType == "team" { - rows, err := s.db.Query( - `SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`, - sh.TargetID, - ) - if err == nil { - for rows.Next() { - var m domain.TaskListMember - if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil { - m.Role = "member" - memberMap[m.UserID] = m - } - } - _ = rows.Close() - } - } else if sh.TargetType == "user" { - var m domain.TaskListMember - if err := s.db.QueryRow(`SELECT id, display_name, email, avatar_url FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil { - m.Role = "member" - memberMap[m.UserID] = m - } - } - } - - result := make([]domain.TaskListMember, 0, len(memberMap)) - for _, m := range memberMap { - result = append(result, m) - } - return result -} - -func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) { - rows, err := s.db.Query( - `SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at - FROM task_lists - WHERE user_id = ? - OR id IN ( - SELECT task_list_id FROM task_list_shares - WHERE (target_type = 'user' AND target_id = ?) - OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - ORDER BY updated_at DESC`, - userID, userID, userID, - ) - if err != nil { - return []domain.TaskList{}, err - } - - defaultCols := []domain.KanbanColumn{ - {ID: "todo", Name: "Zu erledigen"}, - {ID: "in_progress", Name: "In Bearbeitung", Color: "accent"}, - {ID: "waiting", Name: "Wartet auf andere", Color: "amber"}, - {ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"}, - } - - list := make([]domain.TaskList, 0) - for rows.Next() { - var tl domain.TaskList - var colsJSON string - if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil { - tl.IsOwner = (tl.UserID == userID) - _ = json.Unmarshal([]byte(colsJSON), &tl.Columns) - if len(tl.Columns) == 0 { - tl.Columns = defaultCols - } - list = append(list, tl) - } - } - _ = rows.Close() - - for i := range list { - list[i].Members = make([]domain.TaskListMember, 0) - list[i].Shares = make([]domain.TaskListShare, 0) - - _ = s.db.QueryRow( - `SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, - list[i].ID, - ).Scan(&list[i].TasksCount) - _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName) - - shares, _ := s.GetTaskListShares(list[i].ID) - list[i].Shares = shares - list[i].Permission = s.GetTaskListPermission(userID, list[i].ID) - list[i].Members = s.getMembersForTaskList(&list[i]) - } - return list, nil -} - -func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) { - var tl domain.TaskList - var colsJSON string - err := s.db.QueryRow( - `SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at - FROM task_lists - WHERE id = ? AND ( - user_id = ? - OR id IN ( - SELECT task_list_id FROM task_list_shares - WHERE (target_type = 'user' AND target_id = ?) - OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - )`, - listID, userID, userID, userID, - ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt) - if err != nil { - return nil, err - } - tl.IsOwner = (tl.UserID == userID) - _ = json.Unmarshal([]byte(colsJSON), &tl.Columns) - if len(tl.Columns) == 0 { - tl.Columns = []domain.KanbanColumn{ - {ID: "todo", Name: "Zu erledigen"}, - {ID: "in_progress", Name: "In Bearbeitung", Color: "accent"}, - {ID: "waiting", Name: "Wartet auf andere", Color: "amber"}, - {ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"}, - } - } - _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount) - _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName) - shares, _ := s.GetTaskListShares(tl.ID) - tl.Shares = shares - tl.Permission = s.GetTaskListPermission(userID, tl.ID) - tl.Members = s.getMembersForTaskList(&tl) - return &tl, nil -} - -func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) { - tl, err := s.GetTaskListByID(userID, listID) - if err != nil { - return nil, err - } - if tl.Permission == "viewer" { - return nil, ErrTaskListReadOnly - } - if title != nil { - tl.Title = strings.TrimSpace(*title) - } - if icon != nil && *icon != "" { - tl.Icon = *icon - } - if color != nil && *color != "" { - tl.Color = *color - } - if spaceID != nil { - tl.SpaceID = *spaceID - } - if columns != nil { - tl.Columns = *columns - } - tl.UpdatedAt = domain.NowISO() - colsJSON, _ := json.Marshal(tl.Columns) - _, err = s.db.Exec( - `UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, columns = ?, updated_at = ? WHERE id = ?`, - tl.Title, tl.Icon, tl.Color, tl.SpaceID, string(colsJSON), tl.UpdatedAt, listID, - ) - if err != nil { - return nil, err - } - return s.GetTaskListByID(userID, listID) -} - -func (s *Store) DeleteTaskList(userID, listID string) error { - _, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID) - _, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID) - _, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID) - s.PruneTaskAssignees() - return err -} - -// --- ORGANIZATIONS & TEAMS --- - -func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, errors.New("team name required") - } - orgID = strings.TrimSpace(orgID) - - team := &domain.Team{ - ID: domain.NewID("team"), - OrgID: orgID, - Name: name, - Description: strings.TrimSpace(description), - Members: []domain.TeamMember{}, - CreatedAt: domain.NowISO(), - } - _, err := s.db.Exec( - `INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`, - team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt, - ) - if err != nil { - return nil, err - } - return team, nil -} - -func (s *Store) DeleteTeam(teamID string) error { - _, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID) - s.pruneSharingNow() - return err -} - -func (s *Store) GetAllTeams() ([]domain.Team, error) { - rows, err := s.db.Query( - `SELECT id, org_id, name, description, created_at FROM teams ORDER BY name COLLATE NOCASE ASC`, - ) - if err != nil { - return []domain.Team{}, err - } - defer rows.Close() - - teams := make([]domain.Team, 0) - for rows.Next() { - var t domain.Team - if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil { - t.Members = []domain.TeamMember{} - teams = append(teams, t) - } - } - for i := range teams { - members, _ := s.GetTeamMembers(teams[i].ID) - teams[i].Members = members - } - return teams, nil -} - -func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) { - rows, err := s.db.Query( - `SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`, - orgID, - ) - if err != nil { - return []domain.Team{}, err - } - - teams := make([]domain.Team, 0) - for rows.Next() { - var t domain.Team - if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil { - t.Members = []domain.TeamMember{} - teams = append(teams, t) - } - } - _ = rows.Close() - - for i := range teams { - members, _ := s.GetTeamMembers(teams[i].ID) - teams[i].Members = members - } - return teams, nil -} - -func (s *Store) GetTeamByID(teamID string) (*domain.Team, error) { - var t domain.Team - err := s.db.QueryRow( - `SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`, - teamID, - ).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt) - if err != nil { - return nil, err - } - t.Members, _ = s.GetTeamMembers(t.ID) - return &t, nil -} - -func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) { - rows, err := s.db.Query( - `SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at - FROM teams t - JOIN team_members tm ON tm.team_id = t.id - WHERE tm.user_id = ? - ORDER BY t.created_at ASC`, - userID, - ) - if err != nil { - return []domain.Team{}, err - } - - teams := make([]domain.Team, 0) - for rows.Next() { - var t domain.Team - if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil { - t.Members = []domain.TeamMember{} - teams = append(teams, t) - } - } - _ = rows.Close() - - for i := range teams { - members, _ := s.GetTeamMembers(teams[i].ID) - teams[i].Members = members - } - return teams, nil -} - -func (s *Store) UpdateTeam(teamID, name, description string) (*domain.Team, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, errors.New("Name für die Gruppe ist erforderlich") - } - _, err := s.db.Exec(`UPDATE teams SET name = ?, description = ? WHERE id = ?`, name, strings.TrimSpace(description), teamID) - if err != nil { - return nil, err - } - return s.GetTeamByID(teamID) -} - -func (s *Store) GetTeamMemberRole(teamID, userID string) (string, error) { - var role string - err := s.db.QueryRow(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return "", nil - } - return "", err - } - return role, nil -} - -func (s *Store) CountTeamAdmins(teamID string) (int, error) { - var count int - err := s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND role = 'admin'`, teamID).Scan(&count) - return count, err -} - -func (s *Store) AddTeamMember(teamID, userID, role string) error { - role = strings.ToLower(strings.TrimSpace(role)) - if role != "admin" && role != "member" { - role = "member" - } - _, err := s.db.Exec( - `INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?) - ON CONFLICT(team_id, user_id) DO UPDATE SET role = excluded.role`, - teamID, userID, role, domain.NowISO(), - ) - return err -} - -func (s *Store) UpdateTeamMemberRole(teamID, userID, role string) error { - role = strings.ToLower(strings.TrimSpace(role)) - if role != "admin" && role != "member" { - return errors.New("Ungültige Rolle (erlaubt sind: admin, member)") - } - currentRole, err := s.GetTeamMemberRole(teamID, userID) - if err != nil { - return err - } - if currentRole == "" { - return errors.New("Benutzer ist kein Mitglied dieser Gruppe") - } - if currentRole == "admin" && role != "admin" { - adminCount, err := s.CountTeamAdmins(teamID) - if err != nil { - return err - } - if adminCount <= 1 { - return errors.New("Die Gruppe muss mindestens einen Gruppen-Admin behalten") - } - } - _, err = s.db.Exec(`UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?`, role, teamID, userID) - return err -} - -func (s *Store) RemoveTeamMember(teamID, userID string) error { - currentRole, err := s.GetTeamMemberRole(teamID, userID) - if err != nil { - return err - } - if currentRole == "admin" { - adminCount, err := s.CountTeamAdmins(teamID) - if err != nil { - return err - } - if adminCount <= 1 { - return errors.New("Du bist der letzte Gruppen-Admin. Bitte ernenne zuerst einen anderen Admin oder lösche die Gruppe.") - } - } - _, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID) - // Whoever leaves a group also loses the shares they made with it - s.pruneSharingNow() - return err -} - -func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) { - rows, err := s.db.Query( - `SELECT tm.team_id, tm.user_id, u.display_name, u.email, u.avatar_url, tm.role, tm.joined_at - FROM team_members tm - JOIN users u ON u.id = tm.user_id - WHERE tm.team_id = ? - ORDER BY tm.joined_at ASC`, - teamID, - ) - if err != nil { - return []domain.TeamMember{}, err - } - defer rows.Close() - - members := make([]domain.TeamMember, 0) - for rows.Next() { - var m domain.TeamMember - if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL, &m.Role, &m.JoinedAt); err == nil { - members = append(members, m) - } - } - return members, nil -} - -// --- USER INVITES --- - -func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) { - if role != "admin" && role != "member" { - role = "member" - } - if validDays <= 0 || validDays > 365 { - validDays = 7 - } - code := "INV-" + strings.ToUpper(domain.NewID("c")[2:]) - now := time.Now().UTC() - inv := &domain.UserInvite{ - ID: domain.NewID("inv"), - Code: code, - Email: strings.ToLower(strings.TrimSpace(email)), - Role: role, - OrganizationID: strings.TrimSpace(orgID), - CreatedBy: createdBy, - ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339), - CreatedAt: now.Format(time.RFC3339), - } - _, err := s.db.Exec( - `INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`, - inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt, - ) - if err != nil { - return nil, err - } - return inv, nil -} - -func (s *Store) GetUserInvites() ([]domain.UserInvite, error) { - rows, err := s.db.Query( - `SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at - FROM user_invites i - LEFT JOIN organizations o ON o.id = i.organization_id - ORDER BY i.created_at DESC`, - ) - if err != nil { - return []domain.UserInvite{}, err - } - defer rows.Close() - - invites := make([]domain.UserInvite, 0) - for rows.Next() { - var inv domain.UserInvite - if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil { - invites = append(invites, inv) - } - } - return invites, nil -} - -func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) { - code = strings.ToUpper(strings.TrimSpace(code)) - var inv domain.UserInvite - err := s.db.QueryRow( - `SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at - FROM user_invites WHERE UPPER(code) = ?`, - code, - ).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt) - if err != nil { - return nil, errors.New("Ungültiger Einladungscode") - } - if inv.UsedBy != "" { - return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst") - } - if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) { - return nil, errors.New("Dieser Einladungscode ist abgelaufen") - } - return &inv, nil -} - -func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error { - _, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID) - return err -} - -func (s *Store) DeleteUserInvite(inviteID string) error { - _, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID) - return err -} - -// --- SYSTEM SETTINGS --- - -func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls, skipVerify bool) { - cur, err := s.GetSystemSettings() - if err != nil || cur == nil { - return - } - // Only populate DB from env if DB has no SMTP host configured yet and env provides one - if cur.SMTPHost == "" && strings.TrimSpace(host) != "" { - cur.SMTPHost = strings.TrimSpace(host) - if strings.TrimSpace(port) != "" { - cur.SMTPPort = strings.TrimSpace(port) - } - cur.SMTPUser = strings.TrimSpace(user) - cur.SMTPPass = pass - cur.SMTPFrom = strings.TrimSpace(from) - cur.SMTPTLS = tls - cur.SMTPSkipVerify = skipVerify - _ = s.UpdateSystemSettings(cur) - } -} - -func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { - settings := &domain.SystemSettings{ - SharingMode: SharingOrganizations, - AllowRegistration: false, - InstanceName: "MindSift", - SMTPPort: "587", - SMTPTLS: true, - } - - rows, err := s.db.Query(`SELECT key, value FROM system_settings`) - if err != nil { - return settings, nil - } - defer rows.Close() - - for rows.Next() { - var k, v string - if err := rows.Scan(&k, &v); err == nil { - switch k { - case "sharing_mode": - settings.SharingMode = v - case "allow_registration": - settings.AllowRegistration = (v == "true" || v == "1") - case "instance_name": - settings.InstanceName = v - case "smtp_host": - settings.SMTPHost = v - case "smtp_port": - if v != "" { - settings.SMTPPort = v - } - case "smtp_user": - settings.SMTPUser = v - case "smtp_pass": - settings.SMTPPass = v - case "smtp_from": - settings.SMTPFrom = v - case "smtp_tls": - settings.SMTPTLS = (v != "false" && v != "0") - case "smtp_skip_verify": - settings.SMTPSkipVerify = v == "true" - case "allow_private_webhooks": - settings.AllowPrivateWebhooks = v == "true" - } - } - } - return settings, nil -} - -func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { - regStr := "false" - if settings.AllowRegistration { - regStr = "true" - } - tlsStr := "false" - if settings.SMTPTLS { - tlsStr = "true" - } - skipVerifyStr := "false" - if settings.SMTPSkipVerify { - skipVerifyStr = "true" - } - privateHooksStr := "false" - if settings.AllowPrivateWebhooks { - privateHooksStr = "true" - } - if settings.SMTPPort == "" { - settings.SMTPPort = "587" - } - settings.SharingMode = NormalizeSharingMode(settings.SharingMode) - if settings.InstanceName == "" { - settings.InstanceName = "MindSift" - } - _, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES - ('sharing_mode', ?), - ('allow_registration', ?), - ('instance_name', ?), - ('smtp_host', ?), - ('smtp_port', ?), - ('smtp_user', ?), - ('smtp_pass', ?), - ('smtp_from', ?), - ('smtp_tls', ?), - ('smtp_skip_verify', ?), - ('allow_private_webhooks', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - settings.SharingMode, - regStr, - settings.InstanceName, - strings.TrimSpace(settings.SMTPHost), - strings.TrimSpace(settings.SMTPPort), - strings.TrimSpace(settings.SMTPUser), - settings.SMTPPass, - strings.TrimSpace(settings.SMTPFrom), - tlsStr, - skipVerifyStr, - privateHooksStr, - ) - return err -} - -// --- ALARMS --- - -type DueTaskAlarm struct { - TaskID string - UserID string - Title string - Notes string - DueDate string - Priority string - UserEmail string - AlarmChannels []string - AlarmEmail string - DiscordWebhookURL string - CustomWebhookURL string - NotifyBeforeMinutes int -} - -func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) { - rows, err := s.db.Query( - `SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority, - u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes - FROM thoughts t - JOIN users u ON u.id = t.user_id - WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task')) - AND t.is_completed = 0 - AND t.alarm_sent = 0 - AND t.due_date != '' - AND u.is_active = 1`, - ) - if err != nil { - return []DueTaskAlarm{}, err - } - defer rows.Close() - - alarms := make([]DueTaskAlarm, 0) - for rows.Next() { - var a DueTaskAlarm - var channelsRaw string - if err := rows.Scan( - &a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority, - &a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes, - ); err == nil { - a.AlarmChannels = parseAlarmChannels(channelsRaw) - if a.AlarmEmail == "" { - a.AlarmEmail = a.UserEmail - } - alarms = append(alarms, a) - } - } - return alarms, nil -} - -func (s *Store) MarkTaskAlarmSent(taskID string) error { - _, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID) - return err -} diff --git a/internal/storage/system.go b/internal/storage/system.go new file mode 100644 index 0000000..61f1945 --- /dev/null +++ b/internal/storage/system.go @@ -0,0 +1,208 @@ +package storage + +import ( + "encoding/json" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// --- STATS, SEARCH & EXPORT --- + +func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) { + st := &domain.DashboardStats{} + _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'inbox'`, userID).Scan(&st.InboxCount) + // Same tasks as GetTasks without a list ("Alle Aufgaben"): own ones plus those in shared task lists + taskScope := `(user_id = ? OR task_list_id IN (` + s.accessibleTaskListIDsQuery() + `)) AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))` + _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 0`, userID, userID, userID, userID).Scan(&st.TasksOpenCount) + _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 1`, userID, userID, userID, userID).Scan(&st.TasksDoneCount) + _ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount) + _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount) + _ = s.db.QueryRow(` + SELECT COUNT(DISTINCT l.id) + FROM lists l + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.user_id = ? OR ls.id IS NOT NULL`, + userID, userID, userID).Scan(&st.ListsCount) + + // Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) + docRows, err := s.db.Query(` + SELECT blocks_json FROM documents + WHERE (user_id = ? OR id IN (SELECT doc_id FROM doc_shares WHERE user_id = ?)) + AND is_archived = 0`, + userID, userID, + ) + if err == nil { + for docRows.Next() { + var bj string + if err := docRows.Scan(&bj); err == nil && bj != "" { + var blocks []domain.DocumentBlock + if err := json.Unmarshal([]byte(bj), &blocks); err == nil { + for _, b := range blocks { + if b.Type == "todo" && !b.Checked { + st.DocsTodosOpenCount++ + } + } + } + } + } + _ = docRows.Close() + } + + return st, nil +} + +func (s *Store) SearchAll(userID, query string) ([]domain.Thought, []domain.Document, error) { + res, err := s.SearchGlobal(userID, query) + if err != nil { + return nil, nil, err + } + return res.Thoughts, res.Documents, nil +} + +func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { + user, err := s.GetUserByID(userID) + if err != nil { + return nil, err + } + thoughts, _ := s.GetThoughts(userID, "all", 5000) + docs, _ := s.GetDocuments(userID) + lists, _ := s.GetAllListsForUser(userID) + taskLists, _ := s.GetTaskLists(userID) + + return &domain.FullExport{ + ExportedAt: domain.NowISO(), + Version: "1.0.0", + User: *user, + Thoughts: thoughts, + Documents: docs, + Lists: lists, + TaskLists: taskLists, + }, nil +} + +// --- SYSTEM SETTINGS --- + +func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls, skipVerify bool) { + cur, err := s.GetSystemSettings() + if err != nil || cur == nil { + return + } + // Only populate DB from env if DB has no SMTP host configured yet and env provides one + if cur.SMTPHost == "" && strings.TrimSpace(host) != "" { + cur.SMTPHost = strings.TrimSpace(host) + if strings.TrimSpace(port) != "" { + cur.SMTPPort = strings.TrimSpace(port) + } + cur.SMTPUser = strings.TrimSpace(user) + cur.SMTPPass = pass + cur.SMTPFrom = strings.TrimSpace(from) + cur.SMTPTLS = tls + cur.SMTPSkipVerify = skipVerify + _ = s.UpdateSystemSettings(cur) + } +} + +func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { + settings := &domain.SystemSettings{ + SharingMode: SharingOrganizations, + AllowRegistration: false, + InstanceName: "MindSift", + SMTPPort: "587", + SMTPTLS: true, + } + + rows, err := s.db.Query(`SELECT key, value FROM system_settings`) + if err != nil { + return settings, nil + } + defer rows.Close() + + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err == nil { + switch k { + case "sharing_mode": + settings.SharingMode = v + case "allow_registration": + settings.AllowRegistration = (v == "true" || v == "1") + case "instance_name": + settings.InstanceName = v + case "smtp_host": + settings.SMTPHost = v + case "smtp_port": + if v != "" { + settings.SMTPPort = v + } + case "smtp_user": + settings.SMTPUser = v + case "smtp_pass": + settings.SMTPPass = v + case "smtp_from": + settings.SMTPFrom = v + case "smtp_tls": + settings.SMTPTLS = (v != "false" && v != "0") + case "smtp_skip_verify": + settings.SMTPSkipVerify = v == "true" + case "allow_private_webhooks": + settings.AllowPrivateWebhooks = v == "true" + } + } + } + return settings, nil +} + +func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { + regStr := "false" + if settings.AllowRegistration { + regStr = "true" + } + tlsStr := "false" + if settings.SMTPTLS { + tlsStr = "true" + } + skipVerifyStr := "false" + if settings.SMTPSkipVerify { + skipVerifyStr = "true" + } + privateHooksStr := "false" + if settings.AllowPrivateWebhooks { + privateHooksStr = "true" + } + if settings.SMTPPort == "" { + settings.SMTPPort = "587" + } + settings.SharingMode = NormalizeSharingMode(settings.SharingMode) + if settings.InstanceName == "" { + settings.InstanceName = "MindSift" + } + _, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES + ('sharing_mode', ?), + ('allow_registration', ?), + ('instance_name', ?), + ('smtp_host', ?), + ('smtp_port', ?), + ('smtp_user', ?), + ('smtp_pass', ?), + ('smtp_from', ?), + ('smtp_tls', ?), + ('smtp_skip_verify', ?), + ('allow_private_webhooks', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + settings.SharingMode, + regStr, + settings.InstanceName, + strings.TrimSpace(settings.SMTPHost), + strings.TrimSpace(settings.SMTPPort), + strings.TrimSpace(settings.SMTPUser), + settings.SMTPPass, + strings.TrimSpace(settings.SMTPFrom), + tlsStr, + skipVerifyStr, + privateHooksStr, + ) + return err +} diff --git a/internal/storage/tags.go b/internal/storage/tags.go new file mode 100644 index 0000000..a16c42f --- /dev/null +++ b/internal/storage/tags.go @@ -0,0 +1,242 @@ +package storage + +import ( + "errors" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) { + cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#")) + if cleanTag == "" { + return "", errors.New("tag name required") + } + now := domain.NowISO() + _, err := s.db.Exec( + `INSERT INTO tag_pages (user_id, tag, content, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, tag) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`, + userID, cleanTag, content, now, + ) + if err != nil { + return "", err + } + return now, nil +} + +func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) { + rows, err := s.db.Query( + `SELECT t.tag, COUNT(DISTINCT t.document_id || ':' || t.block_id) as count + FROM block_tags t + JOIN documents d ON t.document_id = d.id + LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ? + LEFT JOIN ( + SELECT dts2.doc_id + FROM doc_team_shares dts2 + JOIN team_members tm ON tm.team_id = dts2.team_id + WHERE tm.user_id = ? + ) dts ON d.id = dts.doc_id + LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id + LEFT JOIN ( + SELECT wfs2.folder_id + FROM workspace_folder_shares wfs2 + WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) + OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) wfs ON d.shared_folder_id = wfs.folder_id + WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL))) + AND d.is_archived = 0 + GROUP BY t.tag + ORDER BY count DESC, t.tag ASC`, + userID, userID, userID, userID, userID, userID, userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]domain.TagSummary, 0) + seen := make(map[string]bool) + for rows.Next() { + var ts domain.TagSummary + if err := rows.Scan(&ts.Tag, &ts.Count); err == nil { + results = append(results, ts) + seen[ts.Tag] = true + } + } + _ = rows.Close() + + // Also include tags that have a non-empty tag_pages note even if not currently used in a block + pageRows, err := s.db.Query( + `SELECT tag FROM tag_pages WHERE user_id = ? AND TRIM(content) != '' ORDER BY tag ASC`, + userID, + ) + if err == nil { + for pageRows.Next() { + var t string + if err := pageRows.Scan(&t); err == nil && t != "" && !seen[t] { + results = append(results, domain.TagSummary{Tag: t, Count: 0}) + seen[t] = true + } + } + _ = pageRows.Close() + } + + return results, nil +} + +func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) { + cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#")) + if cleanTag == "" { + return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil + } + + var noteContent, noteUpdatedAt string + _ = s.db.QueryRow( + `SELECT content, updated_at FROM tag_pages WHERE user_id = ? AND tag = ?`, + userID, cleanTag, + ).Scan(¬eContent, ¬eUpdatedAt) + + rows, err := s.db.Query( + `SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id, + COALESCE(u.display_name, '') as owner_name, + COALESCE(s.permission, dts.permission, wfs.permission, '') as shared_permission + FROM block_tags t + JOIN documents d ON t.document_id = d.id + LEFT JOIN users u ON d.user_id = u.id + LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ? + LEFT JOIN ( + SELECT dts2.doc_id, dts2.permission + FROM doc_team_shares dts2 + JOIN team_members tm ON tm.team_id = dts2.team_id + WHERE tm.user_id = ? + ) dts ON d.id = dts.doc_id + LEFT JOIN workspace_shared_folders wsf ON d.shared_folder_id = wsf.id + LEFT JOIN ( + SELECT wfs2.folder_id, wfs2.permission + FROM workspace_folder_shares wfs2 + WHERE (wfs2.target_type = 'user' AND wfs2.target_id = ?) + OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) wfs ON d.shared_folder_id = wfs.folder_id + WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL OR (wsf.id IS NOT NULL AND (wsf.owner_id = ? OR wfs.folder_id IS NOT NULL))) + AND t.tag = ? + AND d.is_archived = 0 + ORDER BY d.updated_at DESC`, + userID, userID, userID, userID, userID, userID, userID, cleanTag, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + type rawMatch struct { + blockID string + docID string + title string + icon string + folderPath string + blocksJSON string + updatedAt string + docUserID string + ownerName string + sharedPermission string + } + + var rawMatches []rawMatch + for rows.Next() { + var rm rawMatch + if err := rows.Scan(&rm.blockID, &rm.docID, &rm.title, &rm.icon, &rm.folderPath, &rm.blocksJSON, &rm.updatedAt, &rm.docUserID, &rm.ownerName, &rm.sharedPermission); err == nil { + rawMatches = append(rawMatches, rm) + } + } + _ = rows.Close() + + // Group rawMatches by docID + docMap := make(map[string][]rawMatch) + var docOrder []string + for _, rm := range rawMatches { + if _, exists := docMap[rm.docID]; !exists { + docOrder = append(docOrder, rm.docID) + } + docMap[rm.docID] = append(docMap[rm.docID], rm) + } + + totalMatches := 0 + docGroups := make([]domain.TagDocumentGroup, 0, len(docOrder)) + + for _, docID := range docOrder { + matches := docMap[docID] + first := matches[0] + + blocks := s.loadDocBlocks(first.docID, first.blocksJSON) + + blockIdxMap := make(map[string]int, len(blocks)) + for i, b := range blocks { + blockIdxMap[b.ID] = i + } + + matchedBlocks := make([]domain.MatchedTagBlock, 0) + processedBlockIDs := make(map[string]bool) + + for _, m := range matches { + if processedBlockIDs[m.blockID] { + continue + } + idx, ok := blockIdxMap[m.blockID] + if !ok { + continue + } + rootBlock := blocks[idx] + processedBlockIDs[rootBlock.ID] = true + + children := make([]domain.DocumentBlock, 0) + rootLevel := rootBlock.Level + for i := idx + 1; i < len(blocks); i++ { + if blocks[i].Level > rootLevel { + children = append(children, blocks[i]) + processedBlockIDs[blocks[i].ID] = true + } else { + break + } + } + + matchedBlocks = append(matchedBlocks, domain.MatchedTagBlock{ + RootBlock: rootBlock, + Children: children, + }) + totalMatches++ + } + + if len(matchedBlocks) == 0 { + continue + } + + isOwner := first.docUserID == userID + permission := "read" + if isOwner { + permission = "owner" + } else if first.sharedPermission != "" { + permission = first.sharedPermission + } + + docGroups = append(docGroups, domain.TagDocumentGroup{ + DocID: first.docID, + Title: first.title, + Icon: first.icon, + FolderPath: first.folderPath, + UpdatedAt: first.updatedAt, + IsOwner: isOwner, + Permission: permission, + OwnerName: first.ownerName, + MatchedBlocks: matchedBlocks, + }) + } + + return &domain.TagDetails{ + Tag: cleanTag, + NoteContent: noteContent, + NoteUpdatedAt: noteUpdatedAt, + Documents: docGroups, + TotalMatches: totalMatches, + }, nil +} diff --git a/internal/storage/tasklists.go b/internal/storage/tasklists.go new file mode 100644 index 0000000..9eb2f7e --- /dev/null +++ b/internal/storage/tasklists.go @@ -0,0 +1,541 @@ +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// --- TASK LISTS --- + +func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string, columns ...[]domain.KanbanColumn) (*domain.TaskList, error) { + if strings.TrimSpace(title) == "" { + title = "Meine Aufgaben" + } + if icon == "" { + icon = "📋" + } + if color == "" { + color = "#2563eb" + } + cols := []domain.KanbanColumn{ + {ID: "todo", Name: "Zu erledigen"}, + {ID: "in_progress", Name: "In Bearbeitung", Color: "accent"}, + {ID: "waiting", Name: "Wartet auf andere", Color: "amber"}, + {ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"}, + } + if len(columns) > 0 && len(columns[0]) > 0 { + cols = columns[0] + } + colsJSON, _ := json.Marshal(cols) + now := domain.NowISO() + tl := &domain.TaskList{ + ID: domain.NewID("tkl"), + UserID: userID, + SpaceID: spaceID, + Title: strings.TrimSpace(title), + Icon: icon, + Color: color, + Columns: cols, + CreatedAt: now, + UpdatedAt: now, + IsOwner: true, + } + _, err := s.db.Exec( + `INSERT INTO task_lists (id, user_id, space_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt, + ) + if err != nil { + return nil, err + } + if teamID != "" && s.CanShareWith(userID, "team", teamID) { + _, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID) + } + return s.GetTaskListByID(userID, tl.ID) +} + +// ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks. +var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)") + +// ErrAssigneeNoAccess is returned when a task should be assigned to someone who cannot see its list. +var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff auf die Liste zugewiesen werden") + +// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user. +func (s *Store) GetTaskListPermission(userID, listID string) string { + var ownerID string + if err := s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&ownerID); err != nil { + return "" + } + if ownerID == userID { + return "owner" + } + rows, err := s.db.Query( + `SELECT permission FROM task_list_shares + WHERE task_list_id = ? AND ( + (target_type = 'user' AND target_id = ?) + OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + )`, + listID, userID, userID, + ) + perm := "" + if err == nil { + for rows.Next() { + var p string + if rows.Scan(&p) == nil { + if p == "viewer" { + if perm == "" { + perm = "viewer" + } + } else { + perm = "editor" + } + } + } + _ = rows.Close() + } + return perm +} + +func (s *Store) CanEditTaskList(userID, listID string) bool { + p := s.GetTaskListPermission(userID, listID) + return p == "owner" || p == "editor" +} + +// ensureTaskListWritable returns ErrTaskListReadOnly if the list is set and the user may not modify it. +func (s *Store) ensureTaskListWritable(userID, listID string) error { + if listID == "" || s.CanEditTaskList(userID, listID) { + return nil + } + return ErrTaskListReadOnly +} + +func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, createdBy string) (*domain.TaskListShare, error) { + if targetType != "team" && targetType != "user" { + return nil, errors.New("ungültiger Freigabetyp") + } + targetID = strings.TrimSpace(targetID) + if targetID == "" { + return nil, errors.New("Ziel-ID erforderlich") + } + if permission != "viewer" { + permission = "editor" + } + if !s.CanShareWith(createdBy, targetType, targetID) { + return nil, ErrTargetNotFound + } + now := domain.NowISO() + share := &domain.TaskListShare{ + ID: domain.NewID("tls"), + TaskListID: taskListID, + TargetType: targetType, + TargetID: targetID, + Permission: permission, + CreatedAt: now, + } + _, err := s.db.Exec( + `INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, permission, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at, permission = excluded.permission`, + share.ID, share.TaskListID, share.TargetType, share.TargetID, share.Permission, share.CreatedAt, createdBy, + ) + if err != nil { + return nil, err + } + _ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`, + taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt) + + if targetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email) + } + return share, nil +} + +func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error { + _, err := s.db.Exec( + `DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, + taskListID, shareIDOrTargetID, shareIDOrTargetID, + ) + s.PruneTaskAssignees() + return err +} + +func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) { + rows, err := s.db.Query( + `SELECT id, task_list_id, target_type, target_id, permission, created_at + FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`, + taskListID, + ) + if err != nil { + return []domain.TaskListShare{}, err + } + defer rows.Close() + + shares := make([]domain.TaskListShare, 0) + for rows.Next() { + var sh domain.TaskListShare + if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.Permission, &sh.CreatedAt); err == nil { + shares = append(shares, sh) + } + } + for i := range shares { + if shares[i].TargetType == "team" { + _ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName) + } else { + _ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, shares[i].TargetID).Scan(&shares[i].TargetName, &shares[i].Email, &shares[i].AvatarURL) + } + } + return shares, nil +} + +// queryUserIDs runs a query returning a single user_id column and collects the distinct IDs. +func (s *Store) queryUserIDs(query string, args ...interface{}) []string { + rows, err := s.db.Query(query, args...) + if err != nil { + return []string{} + } + defer rows.Close() + ids := make([]string, 0) + seen := make(map[string]bool) + for rows.Next() { + var id string + if rows.Scan(&id) == nil && id != "" && !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + return ids +} + +// taskListAccessSQL selects every user ID with access to a task list: owner, direct shares and members of +// shared groups. %[1]s is the list ID expression ("?" or a correlated column). +const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s + UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user' + UNION SELECT tm.user_id FROM team_members tm + WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')` + +func (s *Store) getTaskAssignees(taskID string) []string { + ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID) + return ids +} + +// attachTaskAssignees fills Assignees for a batch of tasks with a single query. +func (s *Store) attachTaskAssignees(tasks []domain.Thought) { + if len(tasks) == 0 { + return + } + idx := make(map[string]int, len(tasks)) + placeholders := make([]string, 0, len(tasks)) + args := make([]interface{}, 0, len(tasks)) + for i := range tasks { + idx[tasks[i].ID] = i + placeholders = append(placeholders, "?") + args = append(args, tasks[i].ID) + } + rows, err := s.db.Query( + `SELECT task_id, user_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY created_at ASC`, + args..., + ) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var taskID, uid string + if rows.Scan(&taskID, &uid) == nil { + if i, ok := idx[taskID]; ok { + tasks[i].Assignees = append(tasks[i].Assignees, uid) + } + } + } +} + +// SetTaskAssignees replaces the assignees of a task. Only editors of the task's list may assign, and only +// people who can see the list are accepted. +func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]string, error) { + thought, err := s.GetThoughtByID(userID, taskID) + if err != nil { + return nil, err + } + if thought.TaskListID == "" { + if len(assignees) > 0 { + return nil, ErrAssigneeNoAccess + } + return []string{}, nil + } + if !s.CanEditTaskList(userID, thought.TaskListID) { + return nil, ErrTaskListReadOnly + } + allowed := make(map[string]bool) + for _, id := range s.GetTaskListUserIDs(thought.TaskListID) { + allowed[id] = true + } + clean := make([]string, 0, len(assignees)) + seen := make(map[string]bool) + for _, id := range assignees { + id = strings.TrimSpace(id) + if id == "" || seen[id] { + continue + } + if !allowed[id] { + return nil, ErrAssigneeNoAccess + } + seen[id] = true + clean = append(clean, id) + } + + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + keep := make([]interface{}, 0, len(clean)+1) + keep = append(keep, taskID) + ph := make([]string, 0, len(clean)) + for _, id := range clean { + keep = append(keep, id) + ph = append(ph, "?") + } + delQuery := `DELETE FROM task_assignees WHERE task_id = ?` + if len(ph) > 0 { + delQuery += ` AND user_id NOT IN (` + strings.Join(ph, ",") + `)` + } + if _, err := tx.Exec(delQuery, keep...); err != nil { + return nil, err + } + now := domain.NowISO() + for _, id := range clean { + if _, err := tx.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, id, now); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.getTaskAssignees(taskID), nil +} + +// PruneTaskAssignees drops every assignment whose user can no longer see the task's list (share removed, +// left the team, team deleted, task moved to another list or out of any list). Called after each change +// that can take access away, so assignments never outlive permissions. +func (s *Store) PruneTaskAssignees() { + _, _ = s.db.Exec( + `DELETE FROM task_assignees WHERE rowid IN ( + SELECT ta.rowid FROM task_assignees ta + LEFT JOIN thoughts t ON t.id = ta.task_id + WHERE t.id IS NULL OR t.task_list_id = '' + OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_id") + `) + )`, + ) +} + +// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares and members of shared teams. +func (s *Store) GetTaskListUserIDs(listID string) []string { + if listID == "" { + return []string{} + } + return s.queryUserIDs(fmt.Sprintf(taskListAccessSQL, "?"), listID, listID, listID) +} + +// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with. +func (s *Store) GetSharedFolderUserIDs(folderID string) []string { + if folderID == "" { + return []string{} + } + return s.queryUserIDs( + `SELECT owner_id FROM workspace_shared_folders WHERE id = ? + UNION SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' + UNION SELECT tm.user_id FROM team_members tm + WHERE tm.team_id IN (SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'team')`, + folderID, folderID, folderID, + ) +} + +func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember { + memberMap := make(map[string]domain.TaskListMember) + + // 1. Owner + var ownerName, ownerEmail, ownerAvatar string + _ = s.db.QueryRow(`SELECT display_name, email, avatar_url FROM users WHERE id = ?`, tl.UserID).Scan(&ownerName, &ownerEmail, &ownerAvatar) + memberMap[tl.UserID] = domain.TaskListMember{ + UserID: tl.UserID, + DisplayName: ownerName, + Email: ownerEmail, + AvatarURL: ownerAvatar, + Role: "owner", + } + + // 2. From task_list_shares + for _, sh := range tl.Shares { + if sh.TargetType == "team" { + rows, err := s.db.Query( + `SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`, + sh.TargetID, + ) + if err == nil { + for rows.Next() { + var m domain.TaskListMember + if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil { + m.Role = "member" + memberMap[m.UserID] = m + } + } + _ = rows.Close() + } + } else if sh.TargetType == "user" { + var m domain.TaskListMember + if err := s.db.QueryRow(`SELECT id, display_name, email, avatar_url FROM users WHERE id = ?`, sh.TargetID).Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil { + m.Role = "member" + memberMap[m.UserID] = m + } + } + } + + result := make([]domain.TaskListMember, 0, len(memberMap)) + for _, m := range memberMap { + result = append(result, m) + } + return result +} + +func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) { + rows, err := s.db.Query( + `SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at + FROM task_lists + WHERE user_id = ? + OR id IN ( + SELECT task_list_id FROM task_list_shares + WHERE (target_type = 'user' AND target_id = ?) + OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + ORDER BY updated_at DESC`, + userID, userID, userID, + ) + if err != nil { + return []domain.TaskList{}, err + } + + defaultCols := []domain.KanbanColumn{ + {ID: "todo", Name: "Zu erledigen"}, + {ID: "in_progress", Name: "In Bearbeitung", Color: "accent"}, + {ID: "waiting", Name: "Wartet auf andere", Color: "amber"}, + {ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"}, + } + + list := make([]domain.TaskList, 0) + for rows.Next() { + var tl domain.TaskList + var colsJSON string + if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil { + tl.IsOwner = (tl.UserID == userID) + _ = json.Unmarshal([]byte(colsJSON), &tl.Columns) + if len(tl.Columns) == 0 { + tl.Columns = defaultCols + } + list = append(list, tl) + } + } + _ = rows.Close() + + for i := range list { + list[i].Members = make([]domain.TaskListMember, 0) + list[i].Shares = make([]domain.TaskListShare, 0) + + _ = s.db.QueryRow( + `SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, + list[i].ID, + ).Scan(&list[i].TasksCount) + _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName) + + shares, _ := s.GetTaskListShares(list[i].ID) + list[i].Shares = shares + list[i].Permission = s.GetTaskListPermission(userID, list[i].ID) + list[i].Members = s.getMembersForTaskList(&list[i]) + } + return list, nil +} + +func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) { + var tl domain.TaskList + var colsJSON string + err := s.db.QueryRow( + `SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at + FROM task_lists + WHERE id = ? AND ( + user_id = ? + OR id IN ( + SELECT task_list_id FROM task_list_shares + WHERE (target_type = 'user' AND target_id = ?) + OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + )`, + listID, userID, userID, userID, + ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt) + if err != nil { + return nil, err + } + tl.IsOwner = (tl.UserID == userID) + _ = json.Unmarshal([]byte(colsJSON), &tl.Columns) + if len(tl.Columns) == 0 { + tl.Columns = []domain.KanbanColumn{ + {ID: "todo", Name: "Zu erledigen"}, + {ID: "in_progress", Name: "In Bearbeitung", Color: "accent"}, + {ID: "waiting", Name: "Wartet auf andere", Color: "amber"}, + {ID: "done", Name: "Erledigt", IsDone: true, Color: "emerald"}, + } + } + _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount) + _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName) + shares, _ := s.GetTaskListShares(tl.ID) + tl.Shares = shares + tl.Permission = s.GetTaskListPermission(userID, tl.ID) + tl.Members = s.getMembersForTaskList(&tl) + return &tl, nil +} + +func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) { + tl, err := s.GetTaskListByID(userID, listID) + if err != nil { + return nil, err + } + if tl.Permission == "viewer" { + return nil, ErrTaskListReadOnly + } + if title != nil { + tl.Title = strings.TrimSpace(*title) + } + if icon != nil && *icon != "" { + tl.Icon = *icon + } + if color != nil && *color != "" { + tl.Color = *color + } + if spaceID != nil { + tl.SpaceID = *spaceID + } + if columns != nil { + tl.Columns = *columns + } + tl.UpdatedAt = domain.NowISO() + colsJSON, _ := json.Marshal(tl.Columns) + _, err = s.db.Exec( + `UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, columns = ?, updated_at = ? WHERE id = ?`, + tl.Title, tl.Icon, tl.Color, tl.SpaceID, string(colsJSON), tl.UpdatedAt, listID, + ) + if err != nil { + return nil, err + } + return s.GetTaskListByID(userID, listID) +} + +func (s *Store) DeleteTaskList(userID, listID string) error { + _, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID) + _, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID) + _, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID) + s.PruneTaskAssignees() + return err +} diff --git a/internal/storage/teams.go b/internal/storage/teams.go new file mode 100644 index 0000000..2c6ce24 --- /dev/null +++ b/internal/storage/teams.go @@ -0,0 +1,247 @@ +package storage + +import ( + "database/sql" + "errors" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// --- ORGANIZATIONS & TEAMS --- + +func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("team name required") + } + orgID = strings.TrimSpace(orgID) + + team := &domain.Team{ + ID: domain.NewID("team"), + OrgID: orgID, + Name: name, + Description: strings.TrimSpace(description), + Members: []domain.TeamMember{}, + CreatedAt: domain.NowISO(), + } + _, err := s.db.Exec( + `INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`, + team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt, + ) + if err != nil { + return nil, err + } + return team, nil +} + +func (s *Store) DeleteTeam(teamID string) error { + _, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID) + s.pruneSharingNow() + return err +} + +func (s *Store) GetAllTeams() ([]domain.Team, error) { + rows, err := s.db.Query( + `SELECT id, org_id, name, description, created_at FROM teams ORDER BY name COLLATE NOCASE ASC`, + ) + if err != nil { + return []domain.Team{}, err + } + defer rows.Close() + + teams := make([]domain.Team, 0) + for rows.Next() { + var t domain.Team + if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil { + t.Members = []domain.TeamMember{} + teams = append(teams, t) + } + } + for i := range teams { + members, _ := s.GetTeamMembers(teams[i].ID) + teams[i].Members = members + } + return teams, nil +} + +func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) { + rows, err := s.db.Query( + `SELECT id, org_id, name, description, created_at FROM teams WHERE org_id = ? ORDER BY created_at ASC`, + orgID, + ) + if err != nil { + return []domain.Team{}, err + } + + teams := make([]domain.Team, 0) + for rows.Next() { + var t domain.Team + if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil { + t.Members = []domain.TeamMember{} + teams = append(teams, t) + } + } + _ = rows.Close() + + for i := range teams { + members, _ := s.GetTeamMembers(teams[i].ID) + teams[i].Members = members + } + return teams, nil +} + +func (s *Store) GetTeamByID(teamID string) (*domain.Team, error) { + var t domain.Team + err := s.db.QueryRow( + `SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`, + teamID, + ).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt) + if err != nil { + return nil, err + } + t.Members, _ = s.GetTeamMembers(t.ID) + return &t, nil +} + +func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) { + rows, err := s.db.Query( + `SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at + FROM teams t + JOIN team_members tm ON tm.team_id = t.id + WHERE tm.user_id = ? + ORDER BY t.created_at ASC`, + userID, + ) + if err != nil { + return []domain.Team{}, err + } + + teams := make([]domain.Team, 0) + for rows.Next() { + var t domain.Team + if err := rows.Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt); err == nil { + t.Members = []domain.TeamMember{} + teams = append(teams, t) + } + } + _ = rows.Close() + + for i := range teams { + members, _ := s.GetTeamMembers(teams[i].ID) + teams[i].Members = members + } + return teams, nil +} + +func (s *Store) UpdateTeam(teamID, name, description string) (*domain.Team, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("Name für die Gruppe ist erforderlich") + } + _, err := s.db.Exec(`UPDATE teams SET name = ?, description = ? WHERE id = ?`, name, strings.TrimSpace(description), teamID) + if err != nil { + return nil, err + } + return s.GetTeamByID(teamID) +} + +func (s *Store) GetTeamMemberRole(teamID, userID string) (string, error) { + var role string + err := s.db.QueryRow(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return "", err + } + return role, nil +} + +func (s *Store) CountTeamAdmins(teamID string) (int, error) { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND role = 'admin'`, teamID).Scan(&count) + return count, err +} + +func (s *Store) AddTeamMember(teamID, userID, role string) error { + role = strings.ToLower(strings.TrimSpace(role)) + if role != "admin" && role != "member" { + role = "member" + } + _, err := s.db.Exec( + `INSERT INTO team_members (team_id, user_id, role, joined_at) VALUES (?, ?, ?, ?) + ON CONFLICT(team_id, user_id) DO UPDATE SET role = excluded.role`, + teamID, userID, role, domain.NowISO(), + ) + return err +} + +func (s *Store) UpdateTeamMemberRole(teamID, userID, role string) error { + role = strings.ToLower(strings.TrimSpace(role)) + if role != "admin" && role != "member" { + return errors.New("Ungültige Rolle (erlaubt sind: admin, member)") + } + currentRole, err := s.GetTeamMemberRole(teamID, userID) + if err != nil { + return err + } + if currentRole == "" { + return errors.New("Benutzer ist kein Mitglied dieser Gruppe") + } + if currentRole == "admin" && role != "admin" { + adminCount, err := s.CountTeamAdmins(teamID) + if err != nil { + return err + } + if adminCount <= 1 { + return errors.New("Die Gruppe muss mindestens einen Gruppen-Admin behalten") + } + } + _, err = s.db.Exec(`UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?`, role, teamID, userID) + return err +} + +func (s *Store) RemoveTeamMember(teamID, userID string) error { + currentRole, err := s.GetTeamMemberRole(teamID, userID) + if err != nil { + return err + } + if currentRole == "admin" { + adminCount, err := s.CountTeamAdmins(teamID) + if err != nil { + return err + } + if adminCount <= 1 { + return errors.New("Du bist der letzte Gruppen-Admin. Bitte ernenne zuerst einen anderen Admin oder lösche die Gruppe.") + } + } + _, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID) + // Whoever leaves a group also loses the shares they made with it + s.pruneSharingNow() + return err +} + +func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) { + rows, err := s.db.Query( + `SELECT tm.team_id, tm.user_id, u.display_name, u.email, u.avatar_url, tm.role, tm.joined_at + FROM team_members tm + JOIN users u ON u.id = tm.user_id + WHERE tm.team_id = ? + ORDER BY tm.joined_at ASC`, + teamID, + ) + if err != nil { + return []domain.TeamMember{}, err + } + defer rows.Close() + + members := make([]domain.TeamMember, 0) + for rows.Next() { + var m domain.TeamMember + if err := rows.Scan(&m.TeamID, &m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL, &m.Role, &m.JoinedAt); err == nil { + members = append(members, m) + } + } + return members, nil +} diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go new file mode 100644 index 0000000..7f339a6 --- /dev/null +++ b/internal/storage/thoughts.go @@ -0,0 +1,639 @@ +package storage + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// --- THOUGHTS & TRIAGE --- + +func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) { + return s.CreateThoughtWithDetails(userID, content, priority, source, dueDate, "", "todo", "") +} + +func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) { + content = strings.TrimSpace(content) + dueDate = domain.NormalizeDue(dueDate) + if content == "" { + return nil, errors.New("content cannot be empty") + } + if priority == "" { + priority = "normal" + } + if source == "" { + source = "manual" + } + if kanbanStatus == "" { + kanbanStatus = "todo" + } + if err := s.ensureTaskListWritable(userID, taskListID); err != nil { + return nil, err + } + + tags := domain.ExtractTags(content) + if strings.EqualFold(source, "voice") { + hasDiktat := false + for _, tag := range tags { + if strings.EqualFold(tag, "diktat") { + hasDiktat = true + break + } + } + if !hasDiktat { + tags = append(tags, "diktat") + } + } + if strings.EqualFold(source, "webhook") { + hasWebhook := false + for _, tag := range tags { + if strings.EqualFold(tag, "webhook") { + hasWebhook = true + break + } + } + if !hasWebhook { + tags = append(tags, "webhook") + } + } + tagsJSON, _ := json.Marshal(tags) + + t := &domain.Thought{ + ID: domain.NewID("th"), + UserID: userID, + Content: content, + Tags: tags, + Priority: priority, + Source: source, + Status: domain.StatusInbox, + DueDate: dueDate, + TaskListID: taskListID, + KanbanStatus: kanbanStatus, + Notes: notes, + CreatedAt: domain.NowISO(), + } + + _, err := s.db.Exec( + `INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`, + t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, + ) + if err != nil { + return nil, err + } + t.Subtasks = []domain.SubTask{} + return t, nil +} + +func scanThought(rows interface { + Scan(dest ...any) error +}) (*domain.Thought, error) { + t := &domain.Thought{} + var tagsStr, subtasksStr, statusStr string + var isCompleted, alarmSent, sortOrder int + err := rows.Scan( + &t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, + &statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID, + &t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, + &t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence, + ) + if err != nil { + return nil, err + } + t.SortOrder = sortOrder + t.Status = domain.ThoughtStatus(statusStr) + t.IsCompleted = isCompleted != 0 + t.AlarmSent = alarmSent != 0 + if t.KanbanStatus == "" { + if t.IsCompleted { + t.KanbanStatus = "done" + } else { + t.KanbanStatus = "todo" + } + } + t.Tags = []string{} + _ = json.Unmarshal([]byte(tagsStr), &t.Tags) + if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") { + parts := strings.SplitN(t.ConvertedRefID, ":", 2) + t.DocID = parts[0] + t.BlockID = parts[1] + } + t.Assignees = []string{} + t.Subtasks = []domain.SubTask{} + if subtasksStr != "" && subtasksStr != "null" { + _ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks) + } + return t, nil +} + +func (s *Store) accessibleTaskListIDsQuery() string { + return `SELECT id FROM task_lists + WHERE user_id = ? + OR id IN ( + SELECT task_list_id FROM task_list_shares + WHERE (target_type = 'user' AND target_id = ?) + OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + )` +} + +func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { + row := s.db.QueryRow( + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`, + thoughtID, userID, userID, userID, userID, + ) + t, err := scanThought(row) + if err != nil { + return nil, err + } + t.Assignees = s.getTaskAssignees(t.ID) + return t, nil +} + +func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) { + if limit <= 0 || limit > 500 { + limit = 200 + } + var rows *sql.Rows + var err error + + if statusFilter != "" && statusFilter != "all" { + rows, err = s.db.Query( + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + FROM thoughts WHERE user_id = ? AND status = ? + ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`, + userID, statusFilter, limit, + ) + } else { + rows, err = s.db.Query( + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + FROM thoughts WHERE user_id = ? + ORDER BY sort_order ASC, created_at DESC LIMIT ?`, + userID, limit, + ) + } + if err != nil { + return nil, err + } + + result := make([]domain.Thought, 0) + for rows.Next() { + t, err := scanThought(rows) + if err == nil { + result = append(result, *t) + } + } + _ = rows.Close() + return result, nil +} + +func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, error) { + var rows *sql.Rows + var err error + if taskListID != "" { + rows, err = s.db.Query( + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + FROM thoughts + WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) + AND task_list_id = ? + AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) + ORDER BY is_completed ASC, sort_order ASC, created_at DESC`, + userID, userID, userID, userID, taskListID, + ) + } else { + rows, err = s.db.Query( + `SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence + FROM thoughts + WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`)) + AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) + ORDER BY is_completed ASC, sort_order ASC, created_at DESC`, + userID, userID, userID, userID, + ) + } + if err != nil { + return nil, err + } + + result := make([]domain.Thought, 0) + for rows.Next() { + t, err := scanThought(rows) + if err == nil { + result = append(result, *t) + } + } + _ = rows.Close() + s.attachTaskAssignees(result) + return result, nil +} + +func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var completed []string // tasks that move into "done" now (repeating ones spawn their successor) + for _, it := range items { + if it.KanbanStatus != nil && *it.KanbanStatus != "" { + isCompleted := 0 + if *it.KanbanStatus == "done" { + isCompleted = 1 + var was int + _ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was) + if was == 0 { + completed = append(completed, it.ID) + } + } + _, err = tx.Exec( + `UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?, + alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`, + it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID, + ) + } else { + _, err = tx.Exec( + `UPDATE thoughts SET sort_order = ? WHERE id = ?`, + it.SortOrder, it.ID, + ) + } + if err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + for _, id := range completed { + s.spawnNextOccurrence(id) + } + return nil +} + +// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item +func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) { + thought, err := s.GetThoughtByID(userID, thoughtID) + if err != nil { + return nil, err + } + + now := domain.NowISO() + newStatus := domain.StatusKept + convertedType := thought.ConvertedType + convertedRefID := thought.ConvertedRefID + dueDate = domain.NormalizeDue(dueDate) + if dueDate == "" { + dueDate = thought.DueDate + } + if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil { + return nil, err + } + if taskListID == "" { + taskListID = thought.TaskListID + } else if err := s.ensureTaskListWritable(userID, taskListID); err != nil { + return nil, err + } + + switch action { + case "discard": + newStatus = domain.StatusDiscarded + case "restore": + newStatus = domain.StatusInbox + case "keep": + newStatus = domain.StatusKept + if convertTo == "task" { + newStatus = domain.StatusConverted + convertedType = "task" + } else if convertTo == "document" { + newStatus = domain.StatusConverted + convertedType = "document" + title := thought.Content + if len(title) > 72 { + title = title[:72] + "…" + } + blocks := make([]domain.DocumentBlock, 0) + + // 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen. + blocks = append(blocks, domain.DocumentBlock{ + ID: domain.NewID("blk"), + Type: "todo", + Content: thought.Content, + Checked: thought.IsCompleted, + Level: 0, + }) + + // 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität). + if strings.TrimSpace(thought.Notes) != "" { + blocks = append(blocks, domain.DocumentBlock{ + ID: domain.NewID("blk"), + Type: "bullet", + Content: strings.TrimSpace(thought.Notes), + Level: 1, + }) + } + if len(thought.Subtasks) > 0 { + for _, st := range thought.Subtasks { + blocks = append(blocks, domain.DocumentBlock{ + ID: domain.NewID("blk"), + Type: "todo", + Content: st.Title, + Checked: st.IsCompleted, + Level: 1, + }) + } + } + var metaItems []string + if thought.DueDate != "" { + metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate)) + } + if thought.Priority != "" && thought.Priority != "normal" { + metaItems = append(metaItems, "Priorität: "+thought.Priority) + } + if len(metaItems) > 0 { + blocks = append(blocks, domain.DocumentBlock{ + ID: domain.NewID("blk"), + Type: "bullet", + Content: strings.Join(metaItems, " • "), + Level: 1, + }) + } + if len(blocks) == 1 { + blocks = append(blocks, domain.DocumentBlock{ + ID: domain.NewID("blk"), + Type: "bullet", + Content: "", + Level: 0, + }) + } + doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false) + if err == nil && doc != nil { + convertedRefID = doc.ID + } + } else if convertTo == "list_item" && targetListID != "" { + newStatus = domain.StatusConverted + convertedType = "list_item" + item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID) + if err == nil && item != nil { + convertedRefID = item.ID + } + } + default: + return nil, fmt.Errorf("invalid triage action: %s", action) + } + + _, err = s.db.Exec( + `UPDATE thoughts + SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?, + alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END + WHERE id = ? AND user_id = ?`, + string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID, + ) + if err != nil { + return nil, err + } + if taskListID != thought.TaskListID { + s.PruneTaskAssignees() + } + + return s.GetThoughtByID(userID, thoughtID) +} + +func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask, tags *[]string) (*domain.Thought, error) { + thought, err := s.GetThoughtByID(userID, thoughtID) + if err != nil { + return nil, err + } + wasCompleted := thought.IsCompleted + oldDue := thought.DueDate + if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil { + return nil, err + } + if taskListID != nil && *taskListID != thought.TaskListID { + if err := s.ensureTaskListWritable(userID, *taskListID); err != nil { + return nil, err + } + } + + if content != nil { + thought.Content = strings.TrimSpace(*content) + thought.Tags = domain.ExtractTags(thought.Content) + } + if tags != nil { + thought.Tags = *tags + } + if isCompleted != nil { + thought.IsCompleted = *isCompleted + if *isCompleted { + thought.KanbanStatus = "done" + } else if thought.KanbanStatus == "done" { + thought.KanbanStatus = "todo" + } + } + if dueDate != nil { + thought.DueDate = domain.NormalizeDue(*dueDate) + } + if priority != nil { + p := strings.ToLower(strings.TrimSpace(*priority)) + if p == "" || p == "none" { + p = "normal" + } + thought.Priority = p + } + if taskListID != nil { + thought.TaskListID = *taskListID + } + if kanbanStatus != nil && *kanbanStatus != "" { + thought.KanbanStatus = *kanbanStatus + if isCompleted == nil { + if *kanbanStatus == "done" { + thought.IsCompleted = true + } else { + thought.IsCompleted = false + } + } + } + if notes != nil { + thought.Notes = *notes + } + if subtasks != nil { + thought.Subtasks = *subtasks + } + + tagsJSON, _ := json.Marshal(thought.Tags) + subtasksJSON, _ := json.Marshal(thought.Subtasks) + completedInt := 0 + if thought.IsCompleted { + completedInt = 1 + } + + _, err = s.db.Exec( + `UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`, + thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID, + ) + if err != nil { + return nil, err + } + // A new due date or a reopened task gets its alarm again + if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) { + _, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID) + thought.AlarmSent = false + } + if taskListID != nil { + s.PruneTaskAssignees() + thought.Assignees = s.getTaskAssignees(thoughtID) + } + if !wasCompleted && thought.IsCompleted { + s.spawnNextOccurrence(thoughtID) + } + return thought, nil +} + +func (s *Store) DeleteThought(userID, thoughtID string) error { + thought, err := s.GetThoughtByID(userID, thoughtID) + if err != nil { + return err + } + if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil { + return err + } + _, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID) + return err +} + +func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) { + res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) { + // Linking changes the block (its tick follows the task), so edit rights are needed + if !CanEditDoc(s.DocRole(userID, docID)) { + return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") + } + t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "") + if err != nil { + return nil, err + } + t.ConvertedType = "document_block" + t.ConvertedRefID = docID + ":" + blockID + t.DocID = docID + t.BlockID = blockID + t.IsCompleted = checked + t.Status = domain.StatusKept + doneInt := 0 + if checked { + doneInt = 1 + t.KanbanStatus = "done" + } + _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`, + t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID) + + s.SetBlockChecked(userID, docID, blockID, checked) + return t, nil +} + +// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened). +// Returns the note ID if the block changed, so the caller can notify its viewers. +func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string { + t, err := s.GetThoughtByID(userID, taskID) + if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") { + return "" + } + docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":") + if s.SetBlockChecked(userID, docID, blockID, isCompleted) { + return docID + } + return "" +} + +// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's +// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change. +func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) { + if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil { + return + } + _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID) +} + +func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) { + if taskID == "" { + return + } + doneInt := 0 + kanban := "todo" + if checked { + doneInt = 1 + kanban = "done" + } + res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END + WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt) + if checked && res != nil { + if n, _ := res.RowsAffected(); n > 0 { + s.spawnNextOccurrence(taskID) + } + } +} + +// --- ALARMS --- + +type DueTaskAlarm struct { + TaskID string + UserID string + Title string + Notes string + DueDate string + Priority string + UserEmail string + AlarmChannels []string + AlarmEmail string + DiscordWebhookURL string + CustomWebhookURL string + NotifyBeforeMinutes int +} + +func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) { + rows, err := s.db.Query( + `SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority, + u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes + FROM thoughts t + JOIN users u ON u.id = t.user_id + WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task')) + AND t.is_completed = 0 + AND t.alarm_sent = 0 + AND t.due_date != '' + AND u.is_active = 1`, + ) + if err != nil { + return []DueTaskAlarm{}, err + } + defer rows.Close() + + alarms := make([]DueTaskAlarm, 0) + for rows.Next() { + var a DueTaskAlarm + var channelsRaw string + if err := rows.Scan( + &a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority, + &a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes, + ); err == nil { + a.AlarmChannels = parseAlarmChannels(channelsRaw) + if a.AlarmEmail == "" { + a.AlarmEmail = a.UserEmail + } + alarms = append(alarms, a) + } + } + return alarms, nil +} + +func (s *Store) MarkTaskAlarmSent(taskID string) error { + _, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID) + return err +} diff --git a/internal/storage/users.go b/internal/storage/users.go new file mode 100644 index 0000000..ae2bec6 --- /dev/null +++ b/internal/storage/users.go @@ -0,0 +1,413 @@ +package storage + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/domain" +) + +// --- USERS --- + +const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, organization_id, created_at` + +func parseAlarmChannels(raw string) []string { + parts := strings.Split(raw, ",") + res := make([]string, 0, len(parts)) + seen := make(map[string]bool) + for _, p := range parts { + p = strings.ToLower(strings.TrimSpace(p)) + if (p == "email" || p == "discord" || p == "webhook") && !seen[p] { + seen[p] = true + res = append(res, p) + } + } + return res +} + +func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) { + u := &domain.User{} + var isActiveInt int + var channelsRaw string + err := row.Scan( + &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName, + &u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail, + &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.OrganizationID, &u.CreatedAt, + ) + if err != nil { + return nil, err + } + u.IsActive = isActiveInt != 0 + if u.TokenVersion <= 0 { + u.TokenVersion = 1 + } + if u.Role == "" { + u.Role = "member" + } + u.AlarmChannels = parseAlarmChannels(channelsRaw) + if u.AlarmEmail == "" { + u.AlarmEmail = u.Email + } + return u, nil +} + +func (s *Store) CountUsers() (int, error) { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) + return count, err +} + +func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) { + cleanEmail := strings.ToLower(strings.TrimSpace(email)) + username := strings.Split(cleanEmail, "@")[0] + return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role) +} + +func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) { + if role != "admin" && role != "member" { + role = "member" + } + cleanEmail := strings.ToLower(strings.TrimSpace(email)) + cleanUsername := strings.ToLower(strings.TrimSpace(username)) + if cleanUsername == "" { + cleanUsername = strings.Split(cleanEmail, "@")[0] + } + u := &domain.User{ + ID: domain.NewID("usr"), + Email: cleanEmail, + Username: cleanUsername, + PasswordHash: passwordHash, + DisplayName: strings.TrimSpace(displayName), + Role: role, + IsActive: true, + TokenVersion: 1, + AlarmChannels: []string{}, + AlarmEmail: cleanEmail, + DiscordWebhookURL: "", + CustomWebhookURL: "", + NotifyBeforeMinutes: 30, + AvatarURL: "", + CreatedAt: domain.NowISO(), + } + _, err := s.db.Exec( + `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`, + u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt, + ) + if err != nil { + return nil, err + } + return u, nil +} + +func (s *Store) GetUserByEmail(email string) (*domain.User, error) { + return s.GetUserByLogin(email) +} + +func (s *Store) GetUserByLogin(login string) (*domain.User, error) { + cleanLogin := strings.ToLower(strings.TrimSpace(login)) + if cleanLogin == "" { + return nil, errors.New("benutzername oder e-mail darf nicht leer sein") + } + row := s.db.QueryRow( + `SELECT `+userSelectCols+` FROM users + WHERE LOWER(email) = ? + OR (username != '' AND LOWER(username) = ?) + OR LOWER(display_name) = ? + OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?) + LIMIT 1`, + cleanLogin, cleanLogin, cleanLogin, cleanLogin, + ) + return scanUser(row) +} + +// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation +func (s *Store) EnsureDemoAccounts() error { + s.initMu.Lock() + defer s.initMu.Unlock() + + adminHash, err := auth.HashPassword("admin") + if err != nil { + return err + } + userHash, err := auth.HashPassword("user") + if err != nil { + return err + } + + // 1. Admin user (admin / admin) + adminCreated := false + adminUser, err := s.GetUserByLogin("admin") + if err != nil || adminUser == nil { + adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin") + if err != nil { + return fmt.Errorf("failed to create demo admin: %w", err) + } + adminCreated = true + } else { + _ = s.UpdateUserPassword(adminUser.ID, adminHash) + _, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID) + } + + // 2. Regular user (user / user) + normalUser, err := s.GetUserByLogin("user") + if err != nil || normalUser == nil { + normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member") + if err != nil { + return fmt.Errorf("failed to create demo user: %w", err) + } + } else { + _ = s.UpdateUserPassword(normalUser.ID, userHash) + _, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID) + } + + // 3. Sample data for the admin, partly shared with the demo user (only on first start). Both accounts + // share an organization so they can see each other in the default sharing mode. + if adminCreated { + if org, err := s.CreateOrganization("Demo"); err == nil { + _ = s.SetUserOrganization(adminUser.ID, org.ID) + _ = s.SetUserOrganization(normalUser.ID, org.ID) + } + _ = s.SeedStarterContent(adminUser) + s.seedDemoShares(adminUser, normalUser) + } + + return nil +} + +func (s *Store) GetUserByID(id string) (*domain.User, error) { + row := s.db.QueryRow( + `SELECT `+userSelectCols+` FROM users WHERE id = ?`, + id, + ) + return scanUser(row) +} + +func (s *Store) GetFirstUser() (*domain.User, error) { + row := s.db.QueryRow( + `SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC LIMIT 1`, + ) + return scanUser(row) +} + +func (s *Store) GetAllUsers() ([]domain.User, error) { + rows, err := s.db.Query( + `SELECT ` + userSelectCols + ` FROM users ORDER BY created_at ASC`, + ) + if err != nil { + return nil, err + } + users := make([]domain.User, 0) + for rows.Next() { + if u, err := scanUser(rows); err == nil { + users = append(users, *u) + } + } + _ = rows.Close() + return users, nil +} + +func (s *Store) UpdateUser( + userID string, + displayName, email, role *string, + isActive *bool, + discordWebhookURL *string, + notifyBeforeMinutes *int, +) (*domain.User, error) { + return s.UpdateUserFull(userID, displayName, email, role, isActive, nil, nil, discordWebhookURL, nil, notifyBeforeMinutes, nil) +} + +func (s *Store) UpdateUserFull( + userID string, + displayName, email, role *string, + isActive *bool, + alarmChannels *[]string, + alarmEmail, discordWebhookURL, customWebhookURL *string, + notifyBeforeMinutes *int, + avatarURL *string, +) (*domain.User, error) { + u, err := s.GetUserByID(userID) + if err != nil { + return nil, err + } + if displayName != nil { + u.DisplayName = strings.TrimSpace(*displayName) + } + if email != nil { + u.Email = strings.ToLower(strings.TrimSpace(*email)) + } + if role != nil && (*role == "admin" || *role == "member") { + u.Role = *role + } + if isActive != nil { + u.IsActive = *isActive + } + if alarmChannels != nil { + u.AlarmChannels = parseAlarmChannels(strings.Join(*alarmChannels, ",")) + } + if alarmEmail != nil { + u.AlarmEmail = strings.TrimSpace(*alarmEmail) + } + if discordWebhookURL != nil { + u.DiscordWebhookURL = strings.TrimSpace(*discordWebhookURL) + } + if customWebhookURL != nil { + u.CustomWebhookURL = strings.TrimSpace(*customWebhookURL) + } + if notifyBeforeMinutes != nil { + u.NotifyBeforeMinutes = *notifyBeforeMinutes + } + if avatarURL != nil { + u.AvatarURL = strings.TrimSpace(*avatarURL) + } + if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) { + u.TokenVersion++ + } + activeInt := 0 + if u.IsActive { + activeInt = 1 + } + channelsStr := strings.Join(u.AlarmChannels, ",") + _, err = s.db.Exec( + `UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`, + u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID, + ) + if err != nil { + return nil, err + } + return u, nil +} + +func (s *Store) UpdateUserPassword(userID, passwordHash string) error { + _, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID) + return err +} + +func (s *Store) DeleteUser(userID string) error { + // Remove owned data properly first (other people's tasks in the user's lists stay theirs) + if err := s.PurgeUserData(userID); err != nil { + return err + } + _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID) + _, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID) + _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID) + // Shares pointing at the deleted account are no longer visible to anyone + s.pruneSharingNow() + return err +} + +// RevokeToken adds an individual JWT to the blacklist until its expiration date +func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error { + hash := auth.HashAPIKey(tokenStr) + _, err := s.db.Exec( + `INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`, + hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339), + ) + // Idempotently prune old expired tokens + _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339)) + return err +} + +// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted +func (s *Store) IsTokenRevoked(tokenStr string) bool { + hash := auth.HashAPIKey(tokenStr) + var count int + err := s.db.QueryRow( + `SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`, + hash, time.Now().UTC().Format(time.RFC3339), + ).Scan(&count) + return err == nil && count > 0 +} + +// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user +func (s *Store) RevokeAllUserTokens(userID string) error { + _, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID) + return err +} + +// --- USER INVITES --- + +func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) { + if role != "admin" && role != "member" { + role = "member" + } + if validDays <= 0 || validDays > 365 { + validDays = 7 + } + code := "INV-" + strings.ToUpper(domain.NewID("c")[2:]) + now := time.Now().UTC() + inv := &domain.UserInvite{ + ID: domain.NewID("inv"), + Code: code, + Email: strings.ToLower(strings.TrimSpace(email)), + Role: role, + OrganizationID: strings.TrimSpace(orgID), + CreatedBy: createdBy, + ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339), + CreatedAt: now.Format(time.RFC3339), + } + _, err := s.db.Exec( + `INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`, + inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt, + ) + if err != nil { + return nil, err + } + return inv, nil +} + +func (s *Store) GetUserInvites() ([]domain.UserInvite, error) { + rows, err := s.db.Query( + `SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at + FROM user_invites i + LEFT JOIN organizations o ON o.id = i.organization_id + ORDER BY i.created_at DESC`, + ) + if err != nil { + return []domain.UserInvite{}, err + } + defer rows.Close() + + invites := make([]domain.UserInvite, 0) + for rows.Next() { + var inv domain.UserInvite + if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil { + invites = append(invites, inv) + } + } + return invites, nil +} + +func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) { + code = strings.ToUpper(strings.TrimSpace(code)) + var inv domain.UserInvite + err := s.db.QueryRow( + `SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at + FROM user_invites WHERE UPPER(code) = ?`, + code, + ).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt) + if err != nil { + return nil, errors.New("Ungültiger Einladungscode") + } + if inv.UsedBy != "" { + return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst") + } + if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) { + return nil, errors.New("Dieser Einladungscode ist abgelaufen") + } + return &inv, nil +} + +func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error { + _, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID) + return err +} + +func (s *Store) DeleteUserInvite(inviteID string) error { + _, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID) + return err +}