refactor: split sqlite.go and server.go by domain (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Pure moves, no behaviour change: the storage layer now has users, apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and system files next to sqlite.go (store, schema, migrations); the API has handlers_*.go per area plus realtime.go and alarms.go next to server.go (server, router, middleware). Declarations were moved verbatim with an AST-based tool; imports pruned with goimports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7bba174454
commit
74c394efaa
26 files changed
+7702
-7467
No files matched your search
@@ -30,8 +30,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
||||||
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
|
| `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` (Schema, Migrationen, gesamte Datenlogik) |
|
| `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/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
||||||
| `internal/ws/` | `hub.go` |
|
| `internal/ws/` | `hub.go` |
|
||||||
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
|
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
|
||||||
|
|||||||
+2
-1
@@ -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-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-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-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-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
||||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | 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 |
|
| 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.
|
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
|
||||||
|
|
||||||
### FLAW-20: Dateigröße und Duplikate
|
### 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).
|
- `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.
|
- 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.
|
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||||
|
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||||
|
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
|
||||||
|
if !s.store.IsTokenRevoked(token) {
|
||||||
|
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
|
||||||
|
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
|
||||||
|
return claims.UserID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Some corporate proxies and reverse proxies (e.g. OpenResty/Nginx or HTTP/2 intermediaries)
|
||||||
|
// strip or alter the Connection header (e.g. "close" or missing) while passing
|
||||||
|
// Sec-WebSocket-Key or Upgrade: websocket. Normalize it so Gorilla Upgrader succeeds.
|
||||||
|
if r.Header.Get("Sec-Websocket-Key") != "" {
|
||||||
|
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
|
||||||
|
r.Header.Set("Connection", "Upgrade")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
|
||||||
|
r.Header.Set("Upgrade", "websocket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID := chi.URLParam(r, "listId")
|
||||||
|
if roomID == "" {
|
||||||
|
roomID = chi.URLParam(r, "spaceId")
|
||||||
|
}
|
||||||
|
if roomID == "" {
|
||||||
|
roomID = r.URL.Query().Get("listId")
|
||||||
|
}
|
||||||
|
if roomID == "" {
|
||||||
|
roomID = r.URL.Query().Get("spaceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := s.authenticateUserFromReq(r)
|
||||||
|
if userID == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.ServeWS(w, r, roomID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
|
roomID := r.URL.Query().Get("listId")
|
||||||
|
if roomID == "" {
|
||||||
|
roomID = r.URL.Query().Get("spaceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := s.authenticateUserFromReq(r)
|
||||||
|
if userID == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var initialRooms []string
|
||||||
|
if roomsParam := r.URL.Query().Get("rooms"); roomsParam != "" {
|
||||||
|
for _, rm := range strings.Split(roomsParam, ",") {
|
||||||
|
rm = strings.TrimSpace(rm)
|
||||||
|
if rm != "" {
|
||||||
|
initialRooms = append(initialRooms, rm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if docID := r.URL.Query().Get("docId"); docID != "" {
|
||||||
|
initialRooms = append(initialRooms, "doc:"+strings.TrimSpace(docID))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.ServeSSE(w, r, roomID, userID, initialRooms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
var req struct {
|
||||||
|
SSEID string `json:"sseId"`
|
||||||
|
Room string `json:"room"`
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
Action string `json:"action"` // "join" or "leave"
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Room == "" && req.DocID != "" {
|
||||||
|
req.Room = "doc:" + req.DocID
|
||||||
|
}
|
||||||
|
if req.SSEID == "" || req.Room == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "sseId und room erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Action == "leave" {
|
||||||
|
s.wsHub.LeaveSSERoomByID(req.SSEID, userID, req.Room)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.wsHub.JoinSSERoomByID(req.SSEID, userID, req.Room) {
|
||||||
|
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Raum")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user