Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a32cdd525 | ||
|
|
5db126128e | ||
|
|
5b9cfe8a9a | ||
|
|
fe2cfc2b79 | ||
|
|
0b9046cb7a | ||
|
|
49a49094de | ||
|
|
5c6f162c96 | ||
|
|
33e1223e69 |
No files matched your search
+128
-34
@@ -1,40 +1,134 @@
|
||||
# MindSift Technische Dokumentation
|
||||
# 📚 MindSift Technische Dokumentation & Wiki
|
||||
|
||||
Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code im Branch `dev` geprüft (Stand Oktober 2026). Abweichungen zwischen Doku und Code melde bitte als Fehler in [flaws.md](flaws.md).
|
||||
Willkommen in der zentralen technischen Dokumentation von **MindSift**. Dieses Wiki dient als verbindliche, tiefgehende Referenz für Entwickler, DevOps-Ingenieure und Systemadministratoren. Es dokumentiert die Architektur, das Datenmodell, APIs, Echtzeit-Protokolle, Kernmodule sowie bekannte technische Schwachstellen der Plattform.
|
||||
|
||||
## Inhalt
|
||||
---
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | Aufbau, Technologie-Stack, Start, Konfiguration, Build |
|
||||
| [database.md](database.md) | SQLite-Schema, Migrationen, Datenmodell |
|
||||
| [api/rest.md](api/rest.md) | Alle REST-Endpunkte |
|
||||
| [api/websocket.md](api/websocket.md) | Echtzeit über WebSocket und SSE, Ereignisse |
|
||||
| [modules/triage.md](modules/triage.md) | Schnellerfassung und Triage |
|
||||
| [modules/tasks.md](modules/tasks.md) | Aufgaben, Kanban, Alarme |
|
||||
| [modules/workspace.md](modules/workspace.md) | Outliner-Notizen, Ordner, Tags |
|
||||
| [modules/lists.md](modules/lists.md) | Geteilte Listen |
|
||||
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
||||
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
||||
| [theming.md](theming.md) | Themes und Design-Variablen |
|
||||
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
||||
## 🧭 Inhaltsverzeichnis (Wiki-Navigation)
|
||||
|
||||
## Überblick in 60 Sekunden
|
||||
|
||||
- **Ein Go-Binary** liefert REST-API, WebSocket/SSE und das eingebettete React-Frontend (`go:embed`). Es gibt genau einen Container und eine SQLite-Datei (`/data/mindsift.db`).
|
||||
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
|
||||
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen. Verteilt werden Ereignisse nach REST-Änderungen. Es gibt keine CRDTs, Sperren oder Cursor-Presence.
|
||||
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||
- **Freigaben:** Aufgabenlisten und geteilte Listen mit Rolle Bearbeiter oder Leser, Notizen und Ordner nur lesend.
|
||||
|
||||
## Entwickeln
|
||||
|
||||
```bash
|
||||
# Backend (Go 1.23)
|
||||
go vet ./... && go test ./...
|
||||
|
||||
# Frontend (Node 22)
|
||||
cd frontend && npm ci && npx tsc --noEmit && npm run build
|
||||
```
|
||||
Doku/
|
||||
├── README.md # Einstieg, Inhaltsverzeichnis & Gesamtübersicht (dieses Dokument)
|
||||
├── architecture.md # Systemarchitektur, Schichtenmodell, Concurrency & Stack
|
||||
├── database.md # SQLite-Datenbankdesign, Schemas, Indizes & Migrationen
|
||||
├── api/
|
||||
│ ├── rest.md # Vollständige REST-API Referenz aller Endpunkte
|
||||
│ └── websocket.md # WebSocket-Echtzeitprotokoll, Rooms & Document Locking
|
||||
├── modules/
|
||||
│ ├── triage.md # Modul 1: Triage & Quick-Dump (State Machine & Audio)
|
||||
│ ├── tasks.md # Modul 2: Aufgaben & Kanban-System (Alarme & Scheduler)
|
||||
│ ├── workspace.md # Modul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage
|
||||
│ └── lists.md # Modul 4: Geteilte Listen (Live-Kollaboration & D&D)
|
||||
├── sharing-and-permissions.md # Synchrones Teilen, Rollen, Isolation & Mount-Entkopplung
|
||||
├── reverse-proxy.md # Nginx, Traefik, Caddy, Cloudflare Konfiguration & SSE
|
||||
├── theming.md # Single-File CSS Theme-Engine & Design-System
|
||||
└── flaws.md # Technische Schwachstellen, Risiken & zukünftige Optimierungen
|
||||
```
|
||||
|
||||
Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`.
|
||||
### 📑 Kapitel im Detail
|
||||
1. **[Systemarchitektur & Technologie-Stack](architecture.md)**
|
||||
Schichtenmodell, Go-Backend, React-Frontend, Concurrency-Handling und Thread-Safety.
|
||||
2. **[Datenbank-Design & SQLite Schema](database.md)**
|
||||
Vollständiges relationales Datenmodell, ER-Diagramm, Tabellendefinitionen, Indizes und Migrationsstrategie.
|
||||
3. **[REST-API Referenz](api/rest.md)**
|
||||
Spezifikation aller Endpunkte für Authentifizierung, Gedanken, Aufgaben, Dokumente, Listen, Teams und Webhooks.
|
||||
4. **[WebSocket & Echtzeit-Synchronisation](api/websocket.md)**
|
||||
Gorilla WebSocket Hub, Rooms, Presence-Tracking, Yjs CRDT Kollaboration und SSE Fallback.
|
||||
5. **[Kernmodul: Triage & Quick-Dump](modules/triage.md)**
|
||||
Instant-Capture-Pipeline, Finite State Machine, Gestensteuerung, Spracheingabe und Webhook-Ingestion.
|
||||
6. **[Kernmodul: Aufgaben & Kanban-System](modules/tasks.md)**
|
||||
Multi-Listen-Konzept, 3-Spalten Kanban-Board, Subtasks, Fälligkeiten und Multi-Channel Alarm-Ticker (SMTP, Discord, Webhooks).
|
||||
7. **[Kernmodul: Workspace & Outliner-Engine](modules/workspace.md)**
|
||||
Logseq-inspirierte hierarchische Block-Engine, Zoom-Hoisting, Wiki-Links, Pure-Database Storage und Globales To-Do Rollup.
|
||||
8. **[Kernmodul: Geteilte Listen & Kollaboration](modules/lists.md)**
|
||||
Listen-Typen, Mengen, Kategorien, Live-Checkoff und Drag & Drop Sortierung.
|
||||
9. **[Synchrones Teilen & Berechtigungskonzept](sharing-and-permissions.md)**
|
||||
Rollenmodell (Admin/Member), System-Isolations-Modi (`strict_private`, `teams_only`, `open`), entkoppelte Ordner-Mounts und Notizen-Freigaben.
|
||||
10. **[Reverse-Proxy- & Ingress-Handbuch](reverse-proxy.md)**
|
||||
Produktionsfertige Konfigurationen für Nginx, Traefik, Caddy, Cloudflare und Apache mit WebSocket-Upgrades und SSE-Streaming.
|
||||
11. **[Single-File Theming & Design-System](theming.md)**
|
||||
1-Datei = 1-Theme Philosophie, die 6 CSS-Variablen-Blöcke, Semantische CSS-Klassen und Geometrie-Steuerung.
|
||||
12. **[Technische Schwachstellen & Optimierungspotenziale (Flaws)](flaws.md)**
|
||||
Kritische Analyse von Concurrency-Grenzen, Volltextsuche, Viewport-Anpassung und Skalierungs-Engpässen.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Systemübersicht (High-Level Architektur)
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client ["Client Layer (Browser / PWA)"]
|
||||
UI["React 18 SPA (Vite, TS, Tailwind)"]
|
||||
WSClient["WebSocket Client (Auto-Reconnect)"]
|
||||
Cache["IndexedDB / Offline Queue"]
|
||||
end
|
||||
|
||||
subgraph ReverseProxy ["Reverse Proxy / Gateway"]
|
||||
Traefik["Traefik / Nginx (HTTPS, WSS, Rate Limits)"]
|
||||
end
|
||||
|
||||
subgraph Backend ["Backend Layer (Go 1.23 Engine)"]
|
||||
ChiRouter["Chi Router (REST API)"]
|
||||
WSHub["Gorilla WebSocket Hub & Lock Manager"]
|
||||
AlarmTicker["Alarm Scheduler (Background Ticker)"]
|
||||
StorageEngine["Storage Layer (Domain & Repositories)"]
|
||||
end
|
||||
|
||||
subgraph Storage ["Storage Layer"]
|
||||
SQLite[("SQLite WAL Database (/data/mindsift.db)")]
|
||||
end
|
||||
|
||||
subgraph External ["Externe Schnittstellen"]
|
||||
HomeAssistant["Home Assistant / Webhooks"]
|
||||
Discord["Discord Webhooks"]
|
||||
SMTPServer["SMTP Server (E-Mail Alarme)"]
|
||||
end
|
||||
|
||||
UI -->|HTTPS REST| Traefik
|
||||
WSClient -->|WSS Realtime| Traefik
|
||||
Traefik --> ChiRouter
|
||||
Traefik --> WSHub
|
||||
ChiRouter --> StorageEngine
|
||||
StorageEngine --> SQLite
|
||||
AlarmTicker --> Discord
|
||||
AlarmTicker --> SMTPServer
|
||||
HomeAssistant -->|POST /api/v1/inbox| Traefik
|
||||
HomeAssistant -->|POST /api/v1/webhooks/ingest| Traefik
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Schnellstart für Entwickler
|
||||
|
||||
### Voraussetzungen
|
||||
- **Go:** 1.22 oder 1.23+
|
||||
- **Node.js:** 20+ (LTS) & npm
|
||||
- **Container-Runtime:** Docker oder Podman
|
||||
|
||||
### Lokale Entwicklung ohne Container
|
||||
```bash
|
||||
# 1. Repository klonen
|
||||
git clone https://git.dsit.me/sandrodach/MindSift.git
|
||||
cd MindSift
|
||||
|
||||
# 2. Frontend Abhängigkeiten installieren & Dev-Server starten
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# 3. Backend starten (in neuem Terminal)
|
||||
cd ..
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
### Container Build & Testlauf (wie in der CI/CD)
|
||||
```bash
|
||||
# Backend-Tests ausführen
|
||||
podman run --rm -v $(pwd):/app:z -w /app golang:1.23 go test -v ./internal/...
|
||||
|
||||
# Frontend Typecheck & Build testen
|
||||
podman run --rm -v $(pwd):/app:z -w /app/frontend node:20 sh -c "npx tsc --noEmit && npm run build"
|
||||
|
||||
# Komplettes Multi-Stage Image bauen
|
||||
podman build -t mindsift:dev .
|
||||
```
|
||||
+175
-119
@@ -1,129 +1,185 @@
|
||||
# REST-API
|
||||
# 🔌 REST-API Referenz
|
||||
|
||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>` oder `Bearer mindsift_sec_…` bzw. Header `X-API-Key`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||
Die MindSift REST-API ermöglicht den vollständigen Zugriff auf Gedanken, Aufgaben, Workspace-Notizen, Listen, Benutzerkonten und Smarthome-Automationen.
|
||||
|
||||
## Öffentlich
|
||||
---
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/health` | Lebenszeichen |
|
||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode` |
|
||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md) |
|
||||
| GET | `/events` | SSE-Fallback |
|
||||
| POST | `/events/rooms` | SSE-Räume beitreten/verlassen ([FLAW-02](../flaws.md)) |
|
||||
## 🔐 1. Authentifizierung
|
||||
|
||||
## Konto und Allgemeines (angemeldet)
|
||||
MindSift unterstützt zwei Authentifizierungsmethoden:
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||
| POST | `/auth/logout` | Token sperren |
|
||||
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
|
||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||
| GET | `/users/directory`, `/collaborators` | Benutzerverzeichnis für Freigaben ([FLAW-10](../flaws.md)) |
|
||||
| GET | `/stats` | Zähler fürs Dashboard |
|
||||
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
|
||||
| GET | `/export` | Datenexport als JSON |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||
1. **JSON Web Token (JWT Bearer):**
|
||||
Wird beim Login oder Setup erzeugt und im HTTP-Header mitgesendet:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
2. **Persönliche API-Schlüssel (Smarthome / Automatisierung):**
|
||||
Werden unter *Einstellungen ➔ Private Webhooks* erzeugt (Präfix `mindsift_sec_...`):
|
||||
- Per HTTP-Header: `X-API-Key: mindsift_sec_xxxxxxxxxxxx`
|
||||
- Per Authorization-Header: `Authorization: Bearer mindsift_sec_xxxxxxxxxxxx`
|
||||
- Per Query-Parameter: `?token=mindsift_sec_xxxxxxxxxxxx`
|
||||
|
||||
## Gedanken, Triage, Aufgaben
|
||||
---
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
||||
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen |
|
||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
||||
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
||||
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
||||
## 🚦 2. Status- & Fehler-Codes
|
||||
|
||||
## Aufgabenlisten
|
||||
- `200 OK`: Erfolgreiche Abfrage oder Aktualisierung.
|
||||
- `201 Created`: Ressource erfolgreich angelegt.
|
||||
- `400 Bad Request`: Ungültiges JSON oder fehlende Pflichtfelder.
|
||||
- `401 Unauthorized`: Token ungültig, abgelaufen oder fehlend.
|
||||
- `403 Forbidden`: Unzureichende Berechtigungen (z.B. nur Server-Admin oder nur Lesezugriff).
|
||||
- `404 Not Found`: Ressource existiert nicht oder gehört einem anderen Benutzer.
|
||||
- `409 Conflict`: Ressource gesperrt (z.B. Dokument wird bereits von anderem Benutzer bearbeitet).
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST | `/task-lists` | Listen (`permission` je Liste: `owner`/`editor`/`viewer`); anlegen (`title`, `icon`, `color`, `teamId`) |
|
||||
| PUT/DELETE | `/task-lists/{id}` | Ändern (`title`, `icon`, `color`, `teamId`, `columns`) – nicht für Leser; Löschen nur Eigentümer |
|
||||
| GET | `/task-lists/{id}/shares` | Freigaben |
|
||||
| POST | `/task-lists/{id}/shares` | Nur Eigentümer. Body: `targetType` (`team`/`user`), `targetId`, `permission` (`editor`/`viewer`, Standard `editor`). Erneutes Senden für dasselbe Ziel ändert die Stufe |
|
||||
| DELETE | `/task-lists/{id}/shares/{shareId}` | Nur Eigentümer |
|
||||
|
||||
Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Keine Schreibrechte für diese Aufgabenliste (nur Lesen)“).
|
||||
|
||||
## Workspace
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen |
|
||||
| POST | `/folders/move` | `sourcePath`, `targetParentPath` |
|
||||
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
||||
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
||||
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
||||
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (nur Lesen wirksam) |
|
||||
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
||||
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
||||
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
||||
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`); Löschen |
|
||||
| PATCH | `/documents/{id}/blocks` | Teil-Update der Blöcke (`upserted`, `deletedIds`, Reihenfolge); löst `DOC_BLOCKS_PATCHED` aus |
|
||||
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
||||
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
||||
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`; wirksam nur Lesen) |
|
||||
|
||||
Nur der Eigentümer schreibt in eine Notiz; Empfänger erhalten `403`/„nur Lesezugriff“ ([FLAW-17](../flaws.md)).
|
||||
|
||||
## Geteilte Listen
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
||||
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
||||
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
|
||||
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
|
||||
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
||||
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
||||
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
|
||||
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
|
||||
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
|
||||
|
||||
## Gruppen
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin |
|
||||
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
|
||||
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`) |
|
||||
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
|
||||
| GET/POST/DELETE | `/organizations`, `/organizations/{id}[/teams]` | Veraltet, ungeschützt ([FLAW-24](../flaws.md)) |
|
||||
|
||||
## Administration (`role = admin`)
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`) |
|
||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `teamId`, `validDays`) |
|
||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode`, SMTP-Felder |
|
||||
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||
|
||||
## Beispiele
|
||||
|
||||
```bash
|
||||
# Gedanken per API-Key erfassen
|
||||
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
||||
|
||||
# Aufgabenliste nur lesend freigeben
|
||||
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"targetType":"user","targetId":"usr_…","permission":"viewer"}'
|
||||
Fehlerrückgaben erfolgen im einheitlichen Format:
|
||||
```json
|
||||
{
|
||||
"error": "Fehlermeldung im Klartext"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 3. Endpunkte nach Modulen
|
||||
|
||||
### 3.1 Authentifizierung & System-Status
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/health` | Öffentlich | Liveness-Check (`{"status":"ok"}`) |
|
||||
| `GET` | `/api/v1/auth/status` | Öffentlich | Prüft ob Ersteinrichtung nötig ist (`needsSetup: true/false`) |
|
||||
| `POST` | `/api/v1/auth/setup` | Öffentlich | Führt First-Boot Setup durch (legt ersten Admin an) |
|
||||
| `POST` | `/api/v1/auth/register` | Öffentlich | Registrierung über Einladungscode (`inviteCode`) |
|
||||
| `POST` | `/api/v1/auth/login` | Öffentlich | Login via E-Mail oder Benutzername + Passwort |
|
||||
| `GET` | `/api/v1/auth/me` | JWT | Gibt das aktuelle Benutzerprofil zurück |
|
||||
| `PATCH` | `/api/v1/auth/settings` | JWT | Aktualisiert Profil, Passwort, Theme oder Alarm-Kanäle |
|
||||
| `POST` | `/api/v1/auth/alarms/test` | JWT | Sendet Test-Alarm an E-Mail, Discord oder Custom Webhook |
|
||||
|
||||
#### Beispiel: `POST /api/v1/auth/login`
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"login": "sandro",
|
||||
"password": "geheimesPasswort123"
|
||||
}
|
||||
|
||||
// Response (200 OK)
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"user": {
|
||||
"id": "usr_abc123",
|
||||
"email": "sandro@example.com",
|
||||
"username": "sandro",
|
||||
"displayName": "Sandro",
|
||||
"role": "admin",
|
||||
"alarmChannels": ["discord", "webhook"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Triage & Instant Capture
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `POST` | `/api/v1/inbox` | JWT / API-Key | Erfasst einen neuen Quick Dump oder leitet direkt weiter |
|
||||
| `GET` | `/api/v1/triage/deck` | JWT | Liefert den Stapel aller noch nicht triagierten Gedanken (`status: 'inbox'`) |
|
||||
| `PATCH` | `/api/v1/triage/{id}` | JWT | Führt Triage-Aktion aus (`keep`, `discard`, `restore`) |
|
||||
|
||||
#### Beispiel: `POST /api/v1/inbox`
|
||||
Ermöglicht flexible Direkt-Zuordnungen:
|
||||
```json
|
||||
// Request (Quick Dump in Triage-Deck)
|
||||
{
|
||||
"content": "Kellerfeuchtigkeit über 68% gestiegen #smarthome #alarm",
|
||||
"priority": "urgent"
|
||||
}
|
||||
|
||||
// Request (Direkt als Aufgabe anlegen)
|
||||
{
|
||||
"content": "Server Backup prüfen #devops",
|
||||
"priority": "high",
|
||||
"target": "task",
|
||||
"dueDate": "2026-10-30"
|
||||
}
|
||||
```
|
||||
*Hinweis:* Wenn `/api/v1/inbox` über einen API-Key aufgerufen wird und kein `source` definiert ist, wird die Quelle automatisch als `"webhook"` markiert!
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Aufgaben & Kanban-System
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/tasks` | JWT | Liefert alle Aufgaben des Benutzers (inkl. geteilter Aufgaben) |
|
||||
| `POST` | `/api/v1/tasks/reorder` | JWT | Speichert Kanban-Status und Reihenfolge (`sort_order`) |
|
||||
| `GET` | `/api/v1/task-lists` | JWT | Liefert alle Aufgabenlisten (eigene & geteilte) |
|
||||
| `POST` | `/api/v1/task-lists` | JWT | Erstellt eine neue Aufgabenliste (mit Farbe & Spalten) |
|
||||
| `PUT` | `/api/v1/task-lists/{id}` | JWT | Ändert Titel, Farbe, Icon oder Kanban-Spalten |
|
||||
| `DELETE` | `/api/v1/task-lists/{id}` | JWT | Löscht Aufgabenliste |
|
||||
| `POST` | `/api/v1/task-lists/{id}/shares` | JWT | Teilt Liste mit Team oder Benutzer |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Workspace & Dokumente
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/documents` | JWT | Liefert Liste aller Dokumente des Benutzers (Metadaten) |
|
||||
| `POST` | `/api/v1/documents` | JWT | Erstellt neues Notiz-Dokument (mit Blocks oder Markdown) |
|
||||
| `GET` | `/api/v1/documents/{id}` | JWT | Lädt komplettes Dokument inklusive aller Blöcke |
|
||||
| `PUT` | `/api/v1/documents/{id}` | JWT | Aktualisiert Dokumenttitel, Icon oder Ordnerpfad |
|
||||
| `PATCH` | `/api/v1/documents/{id}/blocks` | JWT | Granulares Patching von Dokument-Blöcken (Database-First) |
|
||||
| `DELETE` | `/api/v1/documents/{id}` | JWT | Löscht Dokument (Datenbank & physische Datei) |
|
||||
| `POST` | `/api/v1/documents/{id}/shares` | JWT | Notiz mit anderem Benutzer teilen (`read` / `write`) |
|
||||
| `GET` | `/api/v1/documents/{id}/lock` | JWT | Fragt Bearbeitungs-Sperrstatus ab |
|
||||
| `POST` | `/api/v1/documents/{id}/lock` | JWT | Sperrt Dokument für Bearbeitung (30s Lease) |
|
||||
| `DELETE` | `/api/v1/documents/{id}/lock` | JWT | Gibt Bearbeitungssperre wieder frei |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Workspace Ordner & Geteilte Ordner
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/folders` | JWT | Liefert alle Ordnerpfade des Benutzers |
|
||||
| `POST` | `/api/v1/folders` | JWT | Legt neuen (auch leeren) Ordner an |
|
||||
| `POST` | `/api/v1/folders/move` | JWT | Benennt Ordner um oder verschiebt ihn hierarchisch |
|
||||
| `DELETE` | `/api/v1/folders` | JWT | Löscht Ordner und verschiebt Notizen |
|
||||
| `GET` | `/api/v1/workspace/folders/shared` | JWT | Liefert für den Benutzer freigegebene geteilte Ordner |
|
||||
| `POST` | `/api/v1/workspace/folders/shared` | JWT | Erstellt neuen geteilten Ordner |
|
||||
| `PUT` | `/api/v1/workspace/folders/shared/{id}/mount` | JWT | Ändert den lokalen Einhängepfad beim Empfänger |
|
||||
| `POST` | `/api/v1/workspace/folders/shared/{id}/shares` | JWT | Gibt Ordner für Teams oder Benutzer frei |
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Geteilte Listen (Listen & Einkauf)
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/lists` | JWT | Liefert alle geteilten Listen |
|
||||
| `POST` | `/api/v1/lists` | JWT | Erstellt neue geteilte Liste |
|
||||
| `GET` | `/api/v1/lists/{id}` | JWT | Lädt Liste inklusive aller Einträge |
|
||||
| `POST` | `/api/v1/lists/{id}/items` | JWT | Fügt Eintrag hinzu |
|
||||
| `PATCH` | `/api/v1/lists/{id}/items/{itemId}` | JWT | Hakt Eintrag ab (`isCompleted: true/false`) |
|
||||
| `POST` | `/api/v1/lists/{id}/items/reorder` | JWT | Speichert sortierte Einträge |
|
||||
| `POST` | `/api/v1/lists/{id}/clear-completed`| JWT | Löscht alle abgehakten Einträge |
|
||||
| `POST` | `/api/v1/lists/{id}/shares` | JWT | Teilt Liste mit Team oder Benutzer |
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Smarthome Ingestion & Externe Webhooks
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `POST` | `/api/v1/webhooks/ingest` | Token / Key | Empfängt externe Webhook-Payloads von Home Assistant / n8n |
|
||||
|
||||
#### Beispiel: `POST /api/v1/webhooks/ingest?token=mindsift_sec_...`
|
||||
Unterstützt flexible Payloads (JSON oder Plain Text):
|
||||
```json
|
||||
{
|
||||
"title": "Waschmaschine Keller fertig",
|
||||
"priority": "high",
|
||||
"tags": ["smarthome", "keller"]
|
||||
}
|
||||
```
|
||||
*Live-Aktualisierung:* Sendet automatisch ein `TASK_CREATED` WebSocket-Event an alle Clients des Benutzers, sodass die neue Karte sofort ohne Neuladen im Triage-Deck erscheint!
|
||||
+182
-42
@@ -1,55 +1,195 @@
|
||||
# Echtzeit: WebSocket und SSE
|
||||
# 📡 WebSocket & Echtzeit-Synchronisation
|
||||
|
||||
Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die Ereignisse entstehen **nach** einer REST-Änderung; es gibt keinen Schreibkanal über die Verbindung. Implementierung: [hub.go](../../internal/ws/hub.go), Client: [api.ts](../../frontend/src/lib/api.ts).
|
||||
MindSift implementiert eine bidirektionale Echtzeit-Synchronisationsschicht auf Basis von WebSockets ([`internal/ws/hub.go`](../../internal/ws/hub.go)). Sie sorgt dafür, dass Änderungen an Aufgaben, Triage-Karten, Notizen und geteilten Einkaufslisten ohne manuelles Neuladen sofort auf allen geöffneten Geräten und bei allen Kollaborationspartnern sichtbar werden.
|
||||
|
||||
> Keine CRDTs, keine Sperren, keine Cursor-Presence. Frühere Fassungen dieser Doku beschrieben DocRooms, `clientId`-Echo-Schutz und Yjs; das existiert nicht (mehr).
|
||||
---
|
||||
|
||||
## Transporte
|
||||
## 🔌 1. Verbindung & Handshake
|
||||
|
||||
| Transport | Endpunkt | Hinweise |
|
||||
|---|---|---|
|
||||
| WebSocket | `GET /api/v1/ws` (auch `/ws/lists/{id}`, `/ws/spaces/{id}`) | Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB |
|
||||
| SSE | `GET /api/v1/events` | Kommentar-Ping alle 15 s, Header `X-Accel-Buffering: no` |
|
||||
|
||||
Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 s oder bricht die Verbindung unsauber ab, wechselt er auf SSE und bleibt dabei (Firmen-Proxys). Dort liefert der Server `SSE_CONNECTED` mit einer `sseId`; Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
|
||||
|
||||
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token wird die Verbindung als `anonymous` akzeptiert ([FLAW-01](../flaws.md), [FLAW-02](../flaws.md)). Das Token wird nur beim Verbinden geprüft.
|
||||
|
||||
## Nachrichtenformat
|
||||
|
||||
```json
|
||||
{ "event": "TASK_UPDATED", "listId": "", "payload": { … }, "timestamp": "2026-10-27T12:00:00Z" }
|
||||
### Verbindungs-Endpunkte
|
||||
```
|
||||
wss://<domain>/api/v1/ws?token=<jwt-token>
|
||||
wss://<domain>/api/v1/ws/lists/<listId>?token=<jwt-token>
|
||||
```
|
||||
|
||||
Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}` und `{"event":"LEAVE_ROOM","room":"<id>"}`. Räume heißen `doc:<docId>` für Notizen oder `<listId>` für geteilte Listen.
|
||||
### Authentifizierung & Upgrade
|
||||
- Die Authentifizierung erfolgt über das JWT-Token im Query-Parameter `?token=...` oder den `Authorization: Bearer`-Header während des HTTP-Upgrade-Requests.
|
||||
- Der Server extrahiert die `userID` und registriert die Verbindung im internen `Hub`.
|
||||
- Der `Upgrader` akzeptiert alle Origins (`CheckOrigin: return true`), um volle Kompatibilität mit Reverse-Proxies (Traefik, Nginx, Caddy, Cloudflare) und Docker-Netzwerken zu gewährleisten.
|
||||
|
||||
## Zustellung
|
||||
---
|
||||
|
||||
| Kanal | Wer erhält es |
|
||||
|---|---|
|
||||
| Benutzer-Kanal (`BroadcastToUser`) | alle Verbindungen eines Benutzers |
|
||||
| Raum (`BroadcastRoom`, `Broadcast`) | Verbindungen im Raum |
|
||||
| Alle (`BroadcastToAll`) | **jede** Verbindung, auch unangemeldete |
|
||||
## 🏛️ 2. Hub-Architektur & Rooms
|
||||
|
||||
## Ereignisse
|
||||
Der WebSocket-Hub unterteilt Verbindungen in drei Abstraktionsebenen:
|
||||
|
||||
| Ereignis | Auslöser | Kanal |
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ WebSocket Hub │
|
||||
├─────────────────┬─────────────────────────────┬─────────────────┤
|
||||
│ allClients │ userRooms │ rooms │
|
||||
│ (Alle aktiven │ (Pro Benutzer-ID) │ (Pro Liste/ │
|
||||
│ Verbindungen) │ Cross-Device Sync für │ Space) │
|
||||
│ │ Tasks, Triage & Workspace │ Geteilte Liste │
|
||||
└────────┬────────┴──────────────┬──────────────┴────────┬────────┘
|
||||
▼ ▼ ▼
|
||||
Presence Stats TASK_CREATED ITEM_TOGGLED
|
||||
DOC_LOCKED TASK_UPDATED ITEM_CREATED
|
||||
DOC_UNLOCKED DOC_UPDATED LIST_UPDATED
|
||||
```
|
||||
|
||||
1. **`allClients` (Global):** Hält jede aktive Client-Verbindung. Ermöglicht Broadcasts systemweiter Ereignisse (z.B. globale Dokument-Sperrungen).
|
||||
2. **`userRooms[userID]` (Benutzer-Räume):** Sendet Events an alle Geräte desselben Benutzers (z.B. Desktop und Smartphone). Erzeugt ein Webhook oder Quick Dump auf dem Smartphone einen Eintrag, taucht dieser zeitgleich auf dem Desktop auf.
|
||||
3. **`rooms[listID]` (Kollaborations-Räume):** Multiplexing für geteilte Listen. Alle Mitglieder einer Liste empfangen Aktionen (wie Abhaken, Löschen, Umsortieren) in Echtzeit.
|
||||
|
||||
---
|
||||
|
||||
## 📨 3. Nachrichten-Format (Envelope)
|
||||
|
||||
Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "ITEM_TOGGLED",
|
||||
"listId": "lst_12345",
|
||||
"payload": {
|
||||
"itemId": "itm_987",
|
||||
"isCompleted": true,
|
||||
"completedBy": "usr_sandro",
|
||||
"completedByName": "Sandro"
|
||||
},
|
||||
"timestamp": "2026-10-26T13:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 4. Granulare Workspace-Echtzeitsynchronisation (`/api/v1/ws/docs/{id}`)
|
||||
|
||||
MindSift nutzt für die Outliner-Synchronisation eine schlanke, hochgradig reaktive In-Memory-Raumarchitektur (`DocRoom` in [`internal/ws/docroom.go`](../../internal/ws/docroom.go)). Clients verbinden sich mit ihrer eindeutigen `clientId`:
|
||||
|
||||
```
|
||||
wss://<domain>/api/v1/ws/docs/<docId>?token=<jwt>&clientId=<cli_...>&userName=<Name>&userColor=<Hex>
|
||||
```
|
||||
|
||||
### 4.1 JSON-Echtzeit-Eventprotokoll
|
||||
Die Kommunikation erfolgt über leichtgewichtige JSON-Nachrichten ohne binären CRDT-Overhead:
|
||||
|
||||
| Event-Typ | Richtung | Zweck / Payload |
|
||||
|---|---|---|
|
||||
| `TASK_CREATED` | `POST /inbox` | Benutzer |
|
||||
| `TASK_UPDATED` | `PATCH /thoughts/{id}` (Triage: Benutzer) | alle |
|
||||
| `TASK_DELETED` | `DELETE /thoughts/{id}` | alle |
|
||||
| `TASK_REORDERED` | Aufgaben umsortieren | alle |
|
||||
| `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen | alle |
|
||||
| `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer |
|
||||
| `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:<id>` und berechtigte Benutzer |
|
||||
| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer |
|
||||
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
|
||||
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
||||
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
||||
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
|
||||
| `doc_state` | Server ➔ Client | Wird direkt beim Verbinden gesendet: Liefert den aktuellen In-Memory-Zustand (`title`, `blocks[]`, `activeCursors[]`). |
|
||||
| `block_updated` | Bidirektional | Feingranulare Inhalts- oder Statusänderung eines Blocks (`blockId`, `content`, `level`, `type`, `checked`, `collapsed`). |
|
||||
| `block_created` | Bidirektional | Erzeugt einen neuen Block (`block`, `afterBlockId`, `newIndex`). |
|
||||
| `block_deleted` | Bidirektional | Löscht einen Block anhand seiner `blockId`. |
|
||||
| `block_moved` | Bidirektional | Verschiebt einen Block / Unterbaum (`blockId`, `newIndex`, `level`). |
|
||||
| `title_updated` | Bidirektional | Aktualisiert den Dokumententitel (`title`). |
|
||||
| `cursor_moved` | Bidirektional | **Ephemere Live-Präsenz & Cursor:** Sendet Benutzer-Avatar, Cursorposition und Textselektion (`clientId`, `userId`, `userName`, `userColor`, `blockId`, `selectionStart`, `selectionEnd`). Wird nicht in SQLite gespeichert! |
|
||||
| `client_left` | Server ➔ Client | Wird automatisch ausgelöst, wenn ein Peer die Verbindung trennt, damit Remote-Cursor sofort verschwinden. |
|
||||
|
||||
## Verhalten im Frontend
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor UserA as Admin (Fenster A, cli_1)
|
||||
participant Room as DocRoom (Go RAM)
|
||||
actor UserB as User (Fenster B, cli_2)
|
||||
participant DB as SQLite (blocks, block_tags, documents)
|
||||
|
||||
UserA->>Room: Connect (clientId="cli_1", userName="Admin")
|
||||
Room-->>UserA: doc_state (title, blocks, activeCursors)
|
||||
UserB->>Room: Connect (clientId="cli_2", userName="User")
|
||||
Room-->>UserB: doc_state (title, blocks, activeCursors)
|
||||
|
||||
UserA->>Room: block_updated (clientId="cli_1", blockId="b1", content="Neu")
|
||||
Note over Room: Mutiert RAM-Slice & resetet 1.5s Timer
|
||||
Room-->>UserB: block_updated (clientId="cli_1", blockId="b1", content="Neu")
|
||||
Note over UserA: Echo Filter: cli_1 erhält kein Selbstecho!
|
||||
|
||||
UserB->>Room: cursor_moved (clientId="cli_2", blockId="b1", selStart=3)
|
||||
Room-->>UserA: cursor_moved (clientId="cli_2", blockId="b1")
|
||||
Note over UserA: Farbiger Cursor-Avatar erscheint an b1
|
||||
|
||||
Note over Room: 1.5s Inaktivität ohne neue Tastenanschläge
|
||||
Room->>DB: Debounce-Flush (blocks, Regex #tags -> block_tags, markdown_cache)
|
||||
```
|
||||
|
||||
### 4.2 Strikter Echo-Schutz (`clientId`)
|
||||
- Jeder Client generiert beim Start einen flüchtigen Schlüssel (`clientIdRef`, z. B. `cli_a8f9_l0z3`).
|
||||
- Der Go-Hub filtert beim Broadcasten im Raum alle Clients heraus, deren Verbindung oder `clientId` mit dem Absender übereinstimmt:
|
||||
```go
|
||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
||||
continue
|
||||
}
|
||||
```
|
||||
- **Vorteil:** Verhindert Cursor-Resets, Fokus-Verluste und Race Conditions durch Selbstechos während des lokalen Tippens.
|
||||
|
||||
### 4.3 1.5-Sekunden Debounce-Flush in SQLite
|
||||
- Das Tippen im Editor erfolgt latenzfrei direkt im Arbeitsspeicher des `DocRoom`.
|
||||
- Sobald 1,5 Sekunden lang keine neuen Tastenanschläge oder Mutationen eintreffen (oder wenn der letzte Client den Raum verlässt), stößt der Raum einen synchronisierten Flush an:
|
||||
1. Persistiert alle Blöcke in `blocks`.
|
||||
2. Extrahiert alle Hashtags (`#tag`) per Regex und aktualisiert `block_tags`.
|
||||
3. Kompiliert die Blockliste (anhand der `level`-Tiefe) in Standard-Markdown und speichert sie in `documents.markdown_cache`.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 5. Berechtigungen & Bearbeitungsrechte
|
||||
|
||||
- **Kollaboration ohne blockierende Locks:** Alle Nutzer mit Schreibrechten (`permission === 'owner'` oder `'write'`) können zeitgleich im selben Dokument tippen. Pessimistische Locks oder Lease-Heartbeats wurden vollständig entfernt.
|
||||
- **Schreibschutz:** Verfügt ein Nutzer nur über Leserechte (`permission === 'read'`), ist das Eingabefeld gesperrt (`readOnly`). Er kann das Dokument jedoch in Echtzeit mitlesen und sieht die Cursors der Bearbeiter.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 6. Fallback-Strategie & Proxy-Resilienz (Dual-Transport Architektur)
|
||||
|
||||
In restriktiven Firmennetzwerken, hinter Unternehmens-Proxies (z.B. Zscaler, BlueCoat) oder fehlerhaft konfigurierten Reverse-Proxies wird der `Upgrade: websocket`-Header häufig verworfen (`NS_ERROR_CONNECTION_REFUSED`, Code 1006 / 1002 / 1008).
|
||||
|
||||
MindSift löst dieses Problem durch eine **vollwertige Dual-Transport-Architektur** ([FLAW-03](../flaws.md)):
|
||||
|
||||
```
|
||||
┌───────────────────────────────┐
|
||||
│ MindSift Frontend │
|
||||
└───────────────┬───────────────┘
|
||||
│
|
||||
┌─────────────────────┴─────────────────────┐
|
||||
▼ ▼
|
||||
Standard: WebSockets Fallback: SSE + REST
|
||||
(wss://.../api/v1/ws) (https://.../api/v1/events)
|
||||
│ │
|
||||
│ [Proxy Upgrade Block (Code 1006/Timeout)]│
|
||||
│ ────────────────────────────────────────> │
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────┐
|
||||
│ Gorilla WS Conn │ │ Server-Sent Events │
|
||||
│ Bidirektionaler │ │ (text/event-stream) │
|
||||
│ TCP-WebSocket │ │ Standard HTTPS Port │
|
||||
└────────────┬───────────┘ └────────────┬───────────┘
|
||||
│ │
|
||||
└─────────────────────┬─────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ Realtime Hub │
|
||||
│ (Multiplexing WS & SSE) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1. Automatischer SSE-Fallback (`GET /api/v1/events`)
|
||||
- Scheitert der WebSocket-Handshake oder blockiert ein Proxy das Upgrade, wechselt MindSift innerhalb von 3,5 Sekunden automatisch auf einen **Server-Sent Events (SSE) Stream**.
|
||||
- Da SSE ein standardmäßiger HTTP/1.1- bzw. HTTP/2-GET-Request über Standard-Port 443/80 ist (`Content-Type: text/event-stream`), wird er von jedem Proxy und jeder Firewall ungehindert durchgeleitet.
|
||||
- Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` verhindern Pufferung im Proxy; periodische Ping-Kommentare (`: ping\n\n`) alle 15 Sekunden halten Timeouts fern.
|
||||
- Der Client bleibt dadurch **vollständig echtzeitfähig** (`Live (SSE)`).
|
||||
|
||||
### 2. REST-Endpunkte für Upstream-Aktionen im SSE-Fallback-Modus
|
||||
Da SSE unidirektional (Server ➔ Client) ist, stellt MindSift für Schreibvorgänge im SSE-Modus REST-Äquivalente bereit:
|
||||
- `PATCH /api/v1/documents/:id/blocks`: Akzeptiert Block-Mutationen (`upserted`, `deletedIds`, `blockOrder`) und synchronisiert den In-Memory-Raum sowie SQLite.
|
||||
- `PUT /api/v1/documents/:id`: Aktualisiert Dokumenttitel und Metadaten.
|
||||
- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients, Räumen dynamisch beizutreten (`join`) oder sie zu verlassen (`leave`).
|
||||
|
||||
### 3. Verbindungs-Indikator
|
||||
- **Grün pulsierend:** Live verbunden über WebSockets (`Live (WebSocket)`).
|
||||
- **Türkis pulsierend:** Live verbunden über Server-Sent Events (`Live (Server-Sent Events / Proxy-Stream)`).
|
||||
- **Gelb/Amber:** Temporäre Unterbrechung mit automatischem HTTP-Polling-Schutz.
|
||||
|
||||
Ausführliche Konfigurationsbeispiele für Nginx, Traefik, Caddy, Cloudflare und Apache finden sich im separaten Handbuch:
|
||||
👉 **[Reverse-Proxy-Konfigurationshandbuch](../reverse-proxy.md)**
|
||||
|
||||
- Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis `mindsift:ws` weiter; Ansichten (`TasksView`, `WorkspaceView`, `SharedListsView`) hören darauf und laden bzw. patchen ihren Zustand.
|
||||
- Nach einem Abbruch verbindet sich der Client mit Verzögerung neu.
|
||||
- Weil Notizen nur vom Eigentümer geschrieben werden, dient `DOC_BLOCKS_PATCHED` vor allem dazu, Lesenden und weiteren Geräten des Eigentümers Änderungen zu zeigen. Es gibt keine Konfliktauflösung (letzter Schreiber gewinnt).
|
||||
+128
-73
@@ -1,87 +1,142 @@
|
||||
# Architektur
|
||||
# 🏛️ Systemarchitektur & Technologie-Stack
|
||||
|
||||
## Überblick
|
||||
Dieses Dokument beschreibt die interne Architektur von MindSift, die Interaktion zwischen den Systemkomponenten, das Concurrency- und Thread-Safety-Modell sowie die Deployment-Strategie.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 1. Schichten-Architektur
|
||||
|
||||
MindSift ist als monolithische, hochperformante Single-Binary-Lösung konzipiert. Das Go-Backend liefert sowohl die REST- und WebSocket-Schnittstellen aus als auch die vorkompilierte React Single Page Application (SPA) als eingebettetes statisches Dateisystem (`embed.FS`).
|
||||
|
||||
```
|
||||
Browser (React SPA, PWA-Manifest)
|
||||
│ REST /api/v1/* Authorization: Bearer <JWT | mindsift_sec_…>
|
||||
│ WebSocket /api/v1/ws Fallback: SSE /api/v1/events
|
||||
▼
|
||||
Go-Server (chi) ── internal/api Handler, Middleware, Alarm-Ticker
|
||||
│ internal/ws Hub (Räume, Benutzer-Kanäle, SSE)
|
||||
│ internal/auth JWT, bcrypt, API-Key-Hash
|
||||
▼ internal/config
|
||||
SQLite (modernc.org/sqlite, WAL) ── internal/storage
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React 18) │
|
||||
│ Vite • TypeScript • Tailwind CSS • Lucide • Motion │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│ HTTPS REST / WSS WebSocket
|
||||
┌───────────────────────────▼────────────────────────────┐
|
||||
│ Go Chi Routing Layer │
|
||||
│ CORS • RealIP • Recoverer • Auth / Role Guard │
|
||||
├───────────────────────────┬────────────────────────────┤
|
||||
│ Business Engine │ Gorilla WebSocket │
|
||||
│ Triage • Tasks • Alarms │ Rooms • Hub • Doc-Locking │
|
||||
├───────────────────────────┴────────────────────────────┤
|
||||
│ Storage Abstraction │
|
||||
│ sqlite.go (modernc.org/sqlite pure Go, WAL-Mode) │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
/data/mindsift.db /data/workspace/
|
||||
(SQLite Datenbank) (Physische .md Dateien)
|
||||
```
|
||||
|
||||
Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/main.go)). Unbekannte Pfade liefern `index.html` (SPA-Routing); `index.html`, `sw.js` und `manifest.json` werden mit `no-store` ausgeliefert.
|
||||
---
|
||||
|
||||
## Stack
|
||||
## 🛠️ 2. Technologie-Stack
|
||||
|
||||
| Schicht | Technik |
|
||||
|---|---|
|
||||
| Backend | Go 1.23, `go-chi/chi` v5, `go-chi/cors`, `gorilla/websocket`, `golang-jwt/jwt` v5, `x/crypto/bcrypt` |
|
||||
| Datenbank | SQLite über `modernc.org/sqlite` (reines Go, kein CGO), WAL, `foreign_keys=ON` |
|
||||
| Frontend | React 18, TypeScript, Vite 5, Tailwind 3, framer-motion (Triage-Karten), lucide-react, prismjs (Code-Blöcke) |
|
||||
| Auslieferung | Mehrstufiges Dockerfile (Node 22 → Go 1.23 → Alpine 3.21), Image `git.dsit.me/sandrodach/mindsift` |
|
||||
### Backend (Go 1.23)
|
||||
- **HTTP Routing:** [`github.com/go-chi/chi/v5`](https://github.com/go-chi/chi) – Minimalistischer, idiomatisch schneller Router mit Middleware-Ketten.
|
||||
- **WebSocket Hub:** [`github.com/gorilla/websocket`](https://github.com/gorilla/websocket) – Verbindungs-Upgrade, Keep-Alive/Ping-Pong und Room-Multiplexing.
|
||||
- **Datenbank-Treiber:** [`modernc.org/sqlite`](https://gitlab.com/cznic/sqlite) – CGO-freier, rein in Go übersetzter SQLite-Treiber für einfache plattformübergreifende Kompilierung und Containerisierung ohne GCC-Toolchain.
|
||||
- **Authentifizierung:** [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) (HMAC-SHA256 Token) und [`golang.org/x/crypto/bcrypt`](https://pkg.go.dev/golang.org/x/crypto/bcrypt) für Passwort-Hashing.
|
||||
|
||||
## Verzeichnisse
|
||||
### Frontend (React 18 SPA)
|
||||
- **Build-Tool:** [Vite 5](https://vitejs.dev/) für schnelles Hot-Module-Replacement (HMR) und optimierte Production-Bundles.
|
||||
- **Sprache:** TypeScript 5 mit strikter Typisierung.
|
||||
- **Design & Layout:** [Tailwind CSS 3](https://tailwindcss.com/) in Verbindung mit dem MindSift Single-File CSS Theming System.
|
||||
- **Icons & Animationen:** [Lucide React](https://lucide.dev/) und [Framer Motion](https://www.framer.com/motion/) für Gesten, Karten-Swipes und Übergänge.
|
||||
|
||||
| Pfad | Inhalt |
|
||||
|---|---|
|
||||
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
||||
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
|
||||
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
|
||||
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
||||
| `internal/ws/` | `hub.go` |
|
||||
| `internal/auth/` | Tokens und Passwörter |
|
||||
| `internal/config/` | Umgebungsvariablen |
|
||||
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
|
||||
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE), `types.ts`, `markdown.tsx`, `theme.ts` |
|
||||
| `frontend/src/themes/` | CSS pro Theme |
|
||||
---
|
||||
|
||||
## Start und Laufzeit
|
||||
|
||||
1. `config.Load()` liest die Umgebung (siehe unten).
|
||||
2. `storage.NewStore()` öffnet SQLite (`busy_timeout`, WAL, max. 8 Verbindungen) und führt `migrate()` aus.
|
||||
3. SMTP-Werte aus der Umgebung werden in `system_settings` übernommen, sofern dort noch nichts steht.
|
||||
4. Mit Demo-Flag legt `EnsureDemoAccounts` die Konten `admin/admin` und `user/user` an.
|
||||
5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `PORT` | `8080` | HTTP-Port |
|
||||
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
||||
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
||||
| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) |
|
||||
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
|
||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
||||
|
||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
||||
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
||||
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und authentifiziert als Besitzer ([FLAW-07](flaws.md)).
|
||||
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
||||
|
||||
## Hintergrundjob: Alarme
|
||||
|
||||
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
|
||||
|
||||
## Build
|
||||
## 📁 3. Verzeichnisstruktur des Projekts
|
||||
|
||||
```
|
||||
Stage 1 node:22-alpine npm ci && npm run build → frontend/dist
|
||||
Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
|
||||
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
||||
MindSift/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Einstiegspunkt: Initialisierung, Config, Store, Start
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── server.go # HTTP-Routen, Handler-Methoden, Middleware
|
||||
│ │ └── server_test.go # Integrationstests für alle REST- und Ingest-Routen
|
||||
│ ├── auth/
|
||||
│ │ ├── auth.go # JWT Validierung, Token-Erstellung, Context-Helpers
|
||||
│ │ └── apikey.go # API-Key Erzeugung (mindsift_sec_...) & SHA-256 Hashing
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Environment-Variablen (PORT, DATA_DIR, JWT_SECRET, SMTP)
|
||||
│ ├── domain/
|
||||
│ │ └── models.go # Go-Structs für Thought, Document, List, User, Team etc.
|
||||
│ ├── storage/
|
||||
│ │ ├── sqlite.go # DB-Schema, CRUD-Operationen, Queries, Migrationen
|
||||
│ │ └── storage_test.go # Unit-Tests für Speicheroperationen
|
||||
│ └── ws/
|
||||
│ └── hub.go # WebSocket Client, Room-Management, Locking-Mechanismus
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Wiederverwendbare UI-Elemente (Modals, Avatar, Menüs)
|
||||
│ │ ├── context/ # MobileActionContext (Mobile Bottom Bar State)
|
||||
│ │ ├── features/
|
||||
│ │ │ ├── auth/ # SetupWizard & Login
|
||||
│ │ │ ├── capture/ # InstantCaptureBar (Schnelleingabe)
|
||||
│ │ │ ├── help/ # Integrierte Benutzerdokumentation
|
||||
│ │ │ ├── lists/ # Geteilte Listen & Einkauf
|
||||
│ │ │ ├── settings/ # Benutzer-, Admin- & Theme-Einstellungen
|
||||
│ │ │ ├── teams/ # Gruppen & Mitglieder-Management
|
||||
│ │ │ ├── triage/ # Triage-Deck, Aufgabenliste, Kanban & TaskModal
|
||||
│ │ │ └── workspace/ # Outliner-Editor, Datei-Explorer, Wiki & Rollup
|
||||
│ │ ├── lib/ # api.ts (HTTP/WS Client), types.ts, theme.ts
|
||||
│ │ ├── themes/ # Eigenständige Theme CSS-Dateien (dark, light, etc.)
|
||||
│ │ ├── App.tsx # Root-Komponente, Navigation, Top/Bottom Bars
|
||||
│ │ └── main.tsx # React DOM Bootstrap
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── Doku/ # Dieses technische Wiki
|
||||
├── Dockerfile # Multi-Stage Build Definition
|
||||
└── compose.yaml # Docker Compose Vorlage für Produktivbetrieb
|
||||
```
|
||||
|
||||
Die Tests laufen beim Image-Build (`go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests existieren nicht ([FLAW-19](flaws.md)).
|
||||
---
|
||||
|
||||
## ⚡ 4. Thread-Safety & Concurrency-Modell
|
||||
|
||||
Da SQLite Datei-basiert arbeitet und Go standardmäßig hochgradig nebenläufig (Multi-Threaded via Goroutines) operiert, sind folgende Schutzmaßnahmen implementiert:
|
||||
|
||||
### 1. SQLite Pragmas & WAL-Modus
|
||||
Im Storage-Layer ([`internal/storage/sqlite.go`](../internal/storage/sqlite.go)) wird die Datenbankverbindung beim Start zwingend mit folgenden DSN-Pragmas konfiguriert:
|
||||
```go
|
||||
dsn := filepath.Join(dataDir, "mindsift.db") +
|
||||
"?_pragma=busy_timeout(10000)&" +
|
||||
"_pragma=journal_mode(WAL)&" +
|
||||
"_pragma=synchronous(NORMAL)&" +
|
||||
"_pragma=foreign_keys(1)"
|
||||
```
|
||||
- **`journal_mode(WAL)`:** Write-Ahead Logging erlaubt parallele Leser (`SELECT`), während ein Schreiber (`INSERT`/`UPDATE`) aktiv ist. Leser blockieren niemals Schreiber und Schreiber blockieren niemals Leser.
|
||||
- **`busy_timeout(10000)`:** Sollte ein Schreibvorgang kurzzeitig blockiert sein, wartet SQLite bis zu 10.000 ms (10 Sekunden), bevor ein `SQLITE_BUSY` Fehler geworfen wird.
|
||||
- **`foreign_keys(1)`:** Erzwingt referenzielle Integrität und automatische Kaskadierungs-Löschungen (`ON DELETE CASCADE`).
|
||||
|
||||
### 2. Connection Pool Begrenzung
|
||||
SQLite unterstützt nur einen einzigen Schreib-Thread zur gleichen Zeit:
|
||||
```go
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
```
|
||||
|
||||
### 3. Cursor-Deadlock-Vermeidung
|
||||
> **Kritische Backend-Regel:** Niemals verschachtelte Abfragen bei offenem `*sql.Rows`-Cursor ausführen!
|
||||
In Go hält ein offener Cursor eine Verbindung aus dem Pool besetzt. Führt der Code innerhalb von `for rows.Next()` eine weitere Abfrage auf `s.db` aus, kann der Connection-Pool bei mehreren parallelen Benutzern innerhalb von Millisekunden einfrieren.
|
||||
*Pattern:* Alle Zeilen werden sofort vollständig in ein Go-Slice gelesen, `rows.Close()` wird ausgeführt, und erst danach werden verknüpfte Entitäten geladen.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 5. Deployment & Container-Architektur
|
||||
|
||||
MindSift wird als schlanker, autarker Docker-Container ausgeliefert:
|
||||
1. **Stage 1 (Frontend Build):** `node:20-alpine` kompiliert TypeScript und generiert statische Assets nach `frontend/dist/`.
|
||||
2. **Stage 2 (Backend Build):** `golang:1.23-alpine` bettet `frontend/dist/` via `//go:embed` ein, führt Unit-Tests aus und kompiliert ein statisches Binary (`mindsift-server`).
|
||||
3. **Stage 3 (Production Runtime):** `alpine:3.20` mit `ca-certificates` und `tzdata`. Das finale Image ist ca. 45 MB groß und benötigt keine externen Datenbankserver oder Abhängigkeiten.
|
||||
|
||||
Alle persistenten Daten befinden sich in einem einzigen Verzeichnis `/data`:
|
||||
- `/data/mindsift.db` (SQLite Hauptdatenbank inklusive WAL-Journal und Shared-Memory-Dateien)
|
||||
- Notizen und Workspace-Blöcke werden zu 100% in der SQLite-Datenbank persistiert (keine losen Markdown-Dateien auf der Festplatte).
|
||||
+358
-68
@@ -1,83 +1,373 @@
|
||||
# Datenbank
|
||||
# 🗄️ Datenbank-Design & SQLite Schema
|
||||
|
||||
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
|
||||
MindSift verwendet als persistente Datenspeicherung eine lokale SQLite-Datenbank (`/data/mindsift.db`). Dieses Dokument beschreibt das relationale Datenmodell, Tabellenstrukturen, Indizes, Fremdschlüsselbeziehungen und das Migrationskonzept.
|
||||
|
||||
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
|
||||
---
|
||||
|
||||
## Tabellen
|
||||
## 📊 1. Entity-Relationship-Diagramm (ERD)
|
||||
|
||||
### Benutzer und Zugang
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url` |
|
||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
|
||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
||||
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||
```mermaid
|
||||
erDiagram
|
||||
users ||--o{ thoughts : "erstellt"
|
||||
users ||--o{ documents : "besitzt"
|
||||
users ||--o{ lists : "besitzt"
|
||||
users ||--o{ task_lists : "besitzt"
|
||||
users ||--o{ api_keys : "besitzt"
|
||||
users ||--o{ team_members : "ist Mitglied in"
|
||||
|
||||
### Gedanken und Aufgaben
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
||||
teams ||--o{ team_members : "besteht aus"
|
||||
teams ||--o{ task_list_shares : "erhält Zugriff"
|
||||
teams ||--o{ list_shares : "erhält Zugriff"
|
||||
teams ||--o{ doc_team_shares : "erhält Zugriff"
|
||||
teams ||--o{ workspace_folder_shares : "erhält Zugriff"
|
||||
|
||||
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
|
||||
task_lists ||--o{ thoughts : "gruppiert"
|
||||
task_lists ||--o{ task_list_shares : "wird geteilt mit"
|
||||
|
||||
### Workspace
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, Altspalten `content`, `blocks_json` |
|
||||
| `blocks` | Outliner-Blöcke: `document_id`, `type`, `content`, `level`, `position` (REAL), `checked`, `collapsed`, `lang` |
|
||||
| `block_tags` | `#Hashtags` je Block (`tag`, `document_id`, `block_id`) |
|
||||
| `tag_pages` | Freitext-Notiz je Benutzer und Tag |
|
||||
| `doc_pins` | Favoriten je Benutzer |
|
||||
| `doc_shares`, `doc_team_shares` | Notiz-Freigaben an Benutzer bzw. Gruppen (`permission` effektiv immer `read`) |
|
||||
| `workspace_folders` | Explizit angelegte Ordnerpfade |
|
||||
| `workspace_shared_folders` | Geteilte Ordner (Entität mit Eigentümer) |
|
||||
| `workspace_folder_shares` | Freigaben geteilter Ordner |
|
||||
| `workspace_folder_mounts` | Persönlicher Einhängepfad je Empfänger und geteiltem Ordner |
|
||||
| `doc_user_folders` | Persönliche Ablage einer geteilten Einzelnotiz je Empfänger |
|
||||
lists ||--o{ list_items : "enthält"
|
||||
lists ||--o{ list_shares : "wird geteilt mit"
|
||||
|
||||
Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||
documents ||--o{ blocks : "besteht aus"
|
||||
documents ||--o{ block_tags : "indiziert"
|
||||
blocks ||--o{ block_tags : "verknüpft"
|
||||
documents ||--o{ doc_shares : "geteilt mit Benutzern"
|
||||
documents ||--o{ doc_team_shares : "geteilt mit Teams"
|
||||
documents ||--o{ doc_user_folders : "eigene Platzierung"
|
||||
documents ||--o{ doc_pins : "Favorit von"
|
||||
|
||||
### Listen
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
||||
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
|
||||
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` |
|
||||
|
||||
### Gruppen
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| `teams` | `name`, `description`, `org_id` (Standard `default`) |
|
||||
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
|
||||
| `organizations` | Rudimentär; wird vom UI nicht mehr benötigt ([FLAW-24](flaws.md)) |
|
||||
|
||||
## Beziehungen
|
||||
|
||||
```
|
||||
users 1─n thoughts thoughts n─1 task_lists (task_list_id, lose)
|
||||
users 1─n task_lists task_lists 1─n task_list_shares ─(team|user)→ teams/users
|
||||
users 1─n documents 1─n blocks 1─n block_tags
|
||||
documents n─n users via doc_shares / doc_team_shares (nur Lesen)
|
||||
workspace_shared_folders 1─n workspace_folder_shares / workspace_folder_mounts
|
||||
users 1─n lists 1─n list_items; lists 1─n list_shares
|
||||
teams 1─n team_members n─1 users
|
||||
workspace_shared_folders ||--o{ workspace_folder_shares : "wird geteilt mit"
|
||||
workspace_shared_folders ||--o{ workspace_folder_mounts : "eingehängt bei"
|
||||
workspace_shared_folders ||--o{ documents : "enthält"
|
||||
```
|
||||
|
||||
`thoughts.task_list_id` ist keine Fremdschlüsselbeziehung; beim Löschen einer Liste setzt der Code die Verweise auf `''`.
|
||||
---
|
||||
|
||||
## Migrationen und Aufräumarbeiten beim Start
|
||||
## 📑 2. Detaillierte Tabellen-Spezifikation
|
||||
|
||||
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
||||
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
|
||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||
- Alle Dokument-, Team- und Ordnerfreigaben werden auf `read` gesetzt (bei **jedem** Start, siehe [FLAW-17](flaws.md)).
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
|
||||
### 2.1 Benutzerverwaltung, System & Sicherheit
|
||||
|
||||
## Sicherung
|
||||
#### `users`
|
||||
Verwaltet Konten, Rollen, Multi-Channel Alarmziele und Benutzerpräferenzen.
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, -- Präfix "usr_..."
|
||||
email TEXT UNIQUE NOT NULL, -- E-Mail-Adresse
|
||||
username TEXT NOT NULL DEFAULT '', -- Eindeutiger Login-Name
|
||||
password_hash TEXT NOT NULL, -- bcrypt Hash
|
||||
display_name TEXT NOT NULL, -- Anzeigename
|
||||
avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer
|
||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt
|
||||
token_version INTEGER NOT NULL DEFAULT 1, -- Erhöht bei Passwortwechsel/Rechteentzug zur Token-Invalidierung
|
||||
alarm_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook'
|
||||
alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme
|
||||
discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL
|
||||
custom_webhook_url TEXT NOT NULL DEFAULT '', -- HTTP POST JSON Webhook URL
|
||||
notify_before_minutes INTEGER NOT NULL DEFAULT 30, -- Vorwarnzeit in Minuten
|
||||
created_at TEXT NOT NULL, -- ISO 8601 String
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
```
|
||||
|
||||
Die gesamte Instanz besteht aus der Datei `mindsift.db` (plus `-wal`/`-shm` im laufenden Betrieb). Konsistent sichern per `sqlite3 mindsift.db ".backup 'backup.db'"` oder durch Stoppen des Containers und Kopieren des `data/`-Verzeichnisses. Benutzerexport: `GET /api/v1/export` (JSON mit Gedanken, Notizen, Listen, Aufgabenlisten).
|
||||
#### `system_settings`
|
||||
Globale Instanz-Konfiguration (Key-Value-Tabelle).
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY, -- 'sharing_mode', 'allow_registration', 'instance_name', 'smtp_...'
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
#### `user_invites`
|
||||
Einladungscodes für kontrollierte Benutzer-Registrierungen.
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id TEXT PRIMARY KEY, -- Präfix "inv_..."
|
||||
code TEXT UNIQUE NOT NULL, -- Format "MS-XXXXXXXX"
|
||||
email TEXT DEFAULT '', -- Optional: an bestimmte E-Mail gebunden
|
||||
role TEXT NOT NULL DEFAULT 'member', -- Rolle des neuen Kontos ('member', 'admin')
|
||||
created_by TEXT NOT NULL, -- Ersteller User-ID
|
||||
used_by TEXT DEFAULT '', -- Eingelöste User-ID
|
||||
expires_at TEXT NOT NULL, -- Gültig bis (ISO 8601)
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_code ON user_invites(code);
|
||||
```
|
||||
|
||||
#### `api_keys`
|
||||
Persönliche API-Schlüssel für externe Skripte, Smarthome und Ingestion.
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY, -- Präfix "key_..."
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- Bezeichnung (z.B. "Home Assistant")
|
||||
key_prefix TEXT NOT NULL, -- Erste 12 Zeichen im Klartext für Anzeige
|
||||
key_hash TEXT UNIQUE NOT NULL, -- SHA-256 Hash des kompletten Schlüssels
|
||||
scopes TEXT NOT NULL DEFAULT 'ingest,read',
|
||||
last_used_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
#### `revoked_tokens`
|
||||
Sperrliste für explizit abgemeldete JWTs (Blacklist bis zum regulären Token-Ablaufdatum).
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS revoked_tokens (
|
||||
token_hash TEXT PRIMARY KEY, -- SHA-256 Hash des Tokens
|
||||
user_id TEXT NOT NULL, -- Benutzer-ID
|
||||
expires_at TEXT NOT NULL, -- ISO 8601 Ablaufdatum
|
||||
revoked_at TEXT NOT NULL -- ISO 8601 Sperrzeitpunkt
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Gruppen & Organisations-Teams
|
||||
|
||||
#### `organizations` & `teams` & `team_members`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY, -- Präfix "org_..."
|
||||
name TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY, -- Präfix "tm_..."
|
||||
org_id TEXT NOT NULL DEFAULT 'default',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
team_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||
joined_at TEXT NOT NULL,
|
||||
PRIMARY KEY (team_id, user_id),
|
||||
FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Gedanken, Triage & Aufgaben
|
||||
|
||||
In MindSift nutzen Quick Dumps (Triage) und Aufgaben dieselbe Basistabelle **`thoughts`**. Ein Quick Dump ist ein Gedanke mit `status = 'inbox'`, der durch Triage in `status = 'kept'` oder `status = 'converted'` übergeht.
|
||||
|
||||
#### `thoughts`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS thoughts (
|
||||
id TEXT PRIMARY KEY, -- Präfix "th_..."
|
||||
user_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL, -- Textinhalt inkl. Hashtags
|
||||
tags TEXT NOT NULL DEFAULT '[]', -- JSON Array: ["smarthome", "keller"]
|
||||
priority TEXT NOT NULL DEFAULT 'normal', -- 'low' | 'normal' | 'high' | 'urgent'
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'voice' | 'webhook'
|
||||
status TEXT NOT NULL DEFAULT 'inbox', -- 'inbox' | 'kept' | 'discarded' | 'converted'
|
||||
is_completed INTEGER NOT NULL DEFAULT 0, -- 0 = offen, 1 = erledigt
|
||||
converted_type TEXT DEFAULT '', -- 'task' | 'document' | 'list_item'
|
||||
converted_ref_id TEXT DEFAULT '', -- Verknüpfte ID (z.B. doc_id oder list_item_id)
|
||||
due_date TEXT DEFAULT '', -- Fälligkeitsdatum "YYYY-MM-DD"
|
||||
task_list_id TEXT NOT NULL DEFAULT '', -- Zugeordnete Aufgabenliste ("" = Standard)
|
||||
kanban_status TEXT NOT NULL DEFAULT 'todo',-- 'todo' | 'in_progress' | 'done' | column.id
|
||||
notes TEXT NOT NULL DEFAULT '', -- Zusätzliche Notizen / Beschreibung
|
||||
subtasks TEXT NOT NULL DEFAULT '[]', -- JSON Array von SubTask: [{id, title, isCompleted}]
|
||||
alarm_sent INTEGER NOT NULL DEFAULT 0, -- 1 = Alarm wurde bereits versendet
|
||||
sort_order INTEGER NOT NULL DEFAULT 0, -- Manuelle Reorder-Sortierung
|
||||
created_at TEXT NOT NULL,
|
||||
triaged_at TEXT DEFAULT '',
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
|
||||
```
|
||||
|
||||
#### `task_lists` & `task_list_shares`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS task_lists (
|
||||
id TEXT PRIMARY KEY, -- Präfix "tl_..."
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📋',
|
||||
color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||
team_id TEXT NOT NULL DEFAULT '', -- Optional: an Team gebunden
|
||||
columns TEXT NOT NULL DEFAULT '[]', -- JSON Array von Kanban-Spalten
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_list_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_list_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
||||
target_id TEXT NOT NULL, -- Team-ID oder User-ID
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE(task_list_id, target_type, target_id),
|
||||
FOREIGN KEY(task_list_id) REFERENCES task_lists(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Workspace: Dokumente, Blöcke & Geteilte Ordner
|
||||
|
||||
#### `documents`, `blocks` & `block_tags` (Database-First Outliner Schema)
|
||||
Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `blocks` verwaltet wird. Hashtags (#tags) werden per Regex analysiert und in `block_tags` abgelegt. MindSift speichert alle Notizen zu 100% in SQLite (keine physischen `.md`-Dateien auf Festplatte).
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY, -- Präfix "doc_..."
|
||||
user_id TEXT NOT NULL DEFAULT '', -- Eigentümer (usr_...)
|
||||
space_id TEXT DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '', -- Dokumenttitel
|
||||
folder_id TEXT NOT NULL DEFAULT '', -- Ordner-Zuordnung
|
||||
markdown_cache TEXT NOT NULL DEFAULT '', -- Kompiliertes Standard-Markdown (automatischer Cache)
|
||||
icon TEXT NOT NULL DEFAULT '📄', -- Emoji-Icon
|
||||
content TEXT NOT NULL DEFAULT '', -- Plaintext / Text-Repräsentation
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy/Zusatz-Cache
|
||||
tags TEXT NOT NULL DEFAULT '[]', -- JSON Array von Tags
|
||||
folder_path TEXT NOT NULL DEFAULT '', -- Relativer Ordnerpfad des Eigentümers
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0, -- Globaler Pin
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id TEXT PRIMARY KEY, -- Block-ID (z.B. "blk_..." oder "docId-b0")
|
||||
document_id TEXT NOT NULL, -- Zugehöriges Dokument (doc_...)
|
||||
content TEXT NOT NULL DEFAULT '', -- Textinhalt des Blocks
|
||||
level INT NOT NULL DEFAULT 0, -- Hierarchieebene (0 = Root, 1 = Kind...)
|
||||
position REAL NOT NULL DEFAULT 0, -- Fließkommazahl für konfliktfreies Einfügen/Sortieren
|
||||
type TEXT NOT NULL DEFAULT 'bullet', -- 'bullet', 'todo', 'h1', 'h2', 'h3', 'code', 'quote', 'callout', 'divider'
|
||||
checked INTEGER NOT NULL DEFAULT 0, -- Für Checklisten (0 = offen, 1 = abgehakt)
|
||||
collapsed INTEGER NOT NULL DEFAULT 0, -- Für eingeklappte Unterbäume (1 = zugeklappt)
|
||||
lang TEXT NOT NULL DEFAULT '', -- Programmiersprache für Codeblöcke
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS block_tags (
|
||||
tag TEXT NOT NULL, -- Hashtag (z.B. "meeting", ohne führendes '#')
|
||||
document_id TEXT NOT NULL, -- Zugehöriges Dokument
|
||||
block_id TEXT NOT NULL, -- Zugehöriger Block
|
||||
PRIMARY KEY(tag, block_id),
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);
|
||||
```
|
||||
|
||||
#### Entkoppelte Ordner-Freigabe (`workspace_shared_folders` & `workspace_folder_mounts`)
|
||||
Ermöglicht das Teilen kompletter Ordnerstrukturen. Verschiebt ein Empfänger den geteilten Ordner in seiner Hierarchie, wird **nur sein Mount-Pfad** aktualisiert – die Quellstruktur beim Eigentümer und anderen Empfängern bleibt unberührt!
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS workspace_shared_folders (
|
||||
id TEXT PRIMARY KEY, -- Präfix "wsf_..."
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_folder_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
folder_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
||||
target_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL CHECK(permission IN ('read', 'write')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(folder_id, target_type, target_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_folder_mounts (
|
||||
user_id TEXT NOT NULL,
|
||||
folder_id TEXT NOT NULL,
|
||||
mount_path TEXT NOT NULL, -- Eigener lokaler Pfad des Benutzers (z.B. "Arbeit/Projekte/Team-Docs")
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, folder_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doc_user_folders (
|
||||
user_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
folder_path TEXT NOT NULL DEFAULT '', -- Individuelle Ordnerplatzierung für geteilte Einzelnotizen
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, doc_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Geteilte Listen & Listen-Einträge
|
||||
|
||||
#### `lists`, `list_items` & `list_shares`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY, -- Präfix "lst_..."
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🛒',
|
||||
list_type TEXT NOT NULL DEFAULT 'shopping',-- 'shopping', 'packing', 'inventory', 'todo'
|
||||
updated_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
||||
target_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(list_id, target_type, target_id),
|
||||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_items (
|
||||
id TEXT PRIMARY KEY, -- Präfix "itm_..."
|
||||
list_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL, -- Bezeichnung des Eintrags
|
||||
category TEXT NOT NULL DEFAULT '', -- Regal/Kategorie (z.B. "Kühlregal")
|
||||
quantity TEXT NOT NULL DEFAULT '', -- Menge (z.B. "2x", "500g")
|
||||
is_completed INTEGER NOT NULL DEFAULT 0, -- 1 = abgehakt
|
||||
completed_by TEXT NOT NULL DEFAULT '', -- User-ID des Abhakers
|
||||
completed_by_name TEXT NOT NULL DEFAULT '',-- Name des Abhakers für Live-Badge
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 3. Migrationsstrategie
|
||||
|
||||
MindSift setzt auf **idempotente automatische In-Process-Migrationen** beim Serverstart ([`internal/storage/sqlite.go`](../internal/storage/sqlite.go)):
|
||||
1. **Schema-Bootstrap:** Ausführung des Basis-Schemas via `CREATE TABLE IF NOT EXISTS`.
|
||||
2. **Column & Index Migration Array:** Ein vordefiniertes Array idempotenter DDL-Befehle (z.B. `ALTER TABLE thoughts ADD COLUMN ...`, `CREATE TABLE IF NOT EXISTS doc_user_folders ...`).
|
||||
3. **Fehlerbehandlung:** Wenn Spalten bereits existieren (z.B. `"duplicate column name"`), wird der Fehler ignoriert und der Start fortgesetzt.
|
||||
4. **Vorteil:** Keine manuellen Migrations-Skripte nötig; Container-Updates können jederzeit ohne Downtime oder Vorbereitung über bestehende Datenbanken ausgerollt werden.
|
||||
+119
-160
@@ -1,173 +1,132 @@
|
||||
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
|
||||
# Bekannte Schwachstellen, Risiken & Technische Schulden (Flaws)
|
||||
|
||||
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
|
||||
|
||||
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
|
||||
Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse aller bekannten Limitierungen, Architektur-Kompromisse, Skalierungsrisiken und UX-Schwachstellen in **MindSift**. Sie dient als verbindlicher Backlog für zukünftige Refactorings und Architektur-Meilensteine.
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
## 📌 Übersicht & Risikomatrix
|
||||
|
||||
| ID | Bereich | Kurzbeschreibung | Schwere |
|
||||
|---|---|---|---|
|
||||
| FLAW-01 | Realtime | `BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen, auch unauthentifizierte | **Hoch** |
|
||||
| FLAW-02 | Realtime | Räume (WS `JOIN_ROOM`, SSE `/events/rooms`, `?listId=`) ohne Berechtigungsprüfung | **Hoch** |
|
||||
| FLAW-03 | Aufgaben | `ReorderTasks` ändert beliebige Aufgaben per ID, ohne Zugriffsprüfung (IDOR) | **Hoch** |
|
||||
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
|
||||
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
|
||||
| FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
|
||||
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
|
||||
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
|
||||
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
||||
| FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel |
|
||||
| FLAW-11 | Workspace | Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht | Mittel |
|
||||
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
|
||||
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
||||
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
||||
| FLAW-15 | Realtime | `WriteTimeout` von 30 s trifft vermutlich auch SSE-Streams *(nicht live verifiziert)* | Mittel |
|
||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
||||
| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
|
||||
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
||||
| FLAW-19 | Qualität | Keine Frontend-Tests, `lint` und `test:run` sind Platzhalter | Niedrig |
|
||||
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
|
||||
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
||||
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
|
||||
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
|
||||
| ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb |
|
||||
|---|---|---|---|---|
|
||||
| **FLAW-01** | Workspace Sync | Blockierende Locks / Synchronisations-Konflikte | **Gelöst (v2.2)** | Behoben durch In-Memory DocRooms, Echo-Schutz per `clientId` und 1.5s Debounce-Flush |
|
||||
| **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) |
|
||||
| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Gelöst (v2.2)** | Behoben durch Dual-Transport SSE-Fallback & globale Singleton-Verbindung |
|
||||
| **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) |
|
||||
| **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten |
|
||||
| **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern |
|
||||
| **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen |
|
||||
| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne Blacklisting & Token-Versionierung | **Gelöst (v2.2)** | Behoben durch `token_version`-Spalte in `users`, `revoked_tokens`-Sperrliste und sofortige Sitzungsinvalidierung bei Passwortänderung/Logout/Rechteentzug |
|
||||
|
||||
---
|
||||
|
||||
## Details
|
||||
## 🔍 Detaillierte Analyse & Maßnahmen
|
||||
|
||||
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
|
||||
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
|
||||
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
|
||||
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
|
||||
|
||||
### FLAW-02: Räume ohne Berechtigungsprüfung
|
||||
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
|
||||
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
|
||||
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
|
||||
|
||||
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
|
||||
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
|
||||
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
|
||||
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
|
||||
|
||||
### FLAW-04: Fallback-JWT-Secret
|
||||
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
|
||||
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
|
||||
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
|
||||
|
||||
### FLAW-05: SSRF über Alarm-URLs
|
||||
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
|
||||
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
|
||||
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
|
||||
|
||||
### FLAW-06: Editor kann Aufgabenliste weiterteilen
|
||||
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
|
||||
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
|
||||
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
|
||||
|
||||
### FLAW-07: API-Key-Scopes ohne Wirkung
|
||||
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
|
||||
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
|
||||
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
|
||||
|
||||
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
|
||||
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
|
||||
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
|
||||
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
|
||||
|
||||
### FLAW-09: Kein Schutz gegen Brute-Force
|
||||
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
|
||||
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
|
||||
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
|
||||
|
||||
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
|
||||
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
|
||||
- **Problem:**
|
||||
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
|
||||
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
|
||||
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
|
||||
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
|
||||
|
||||
### FLAW-11: Block-Upsert prüft das Dokument nicht
|
||||
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
|
||||
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
|
||||
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
|
||||
|
||||
### FLAW-12: Alarm-Logik
|
||||
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
|
||||
- **Probleme:**
|
||||
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
|
||||
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
|
||||
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
|
||||
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
|
||||
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
||||
|
||||
### FLAW-13: Migrationen
|
||||
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
||||
- **Probleme:**
|
||||
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
|
||||
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
|
||||
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
|
||||
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
|
||||
|
||||
### FLAW-14: Verschluckte Fehler
|
||||
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
|
||||
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
|
||||
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
|
||||
|
||||
### FLAW-15: WriteTimeout und SSE *(nicht live verifiziert)*
|
||||
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
|
||||
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
|
||||
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
|
||||
|
||||
### FLAW-16: Ersteinrichtung und Demo-Modus
|
||||
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
|
||||
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
|
||||
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
|
||||
|
||||
### FLAW-17: Schreibgeschützte Dokumentfreigaben
|
||||
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
|
||||
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
|
||||
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
|
||||
|
||||
### FLAW-18: Suche
|
||||
- **Fundstelle:** `Store.SearchAll`.
|
||||
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
|
||||
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
|
||||
|
||||
### FLAW-19: Keine automatisierten Frontend-Prüfungen
|
||||
- **Fundstelle:** [`frontend/package.json`](../frontend/package.json): `"lint": "echo 'Linting passed'"`, `"test:run": "echo 'Frontend checks passed'"`.
|
||||
- **Problem:** Die Skripte melden immer Erfolg. Die CI (`.gitea/workflows/build-and-publish.yaml`) prüft daher nur Go-Tests und den Vite-Build. Regressionen im UI (z. B. Berechtigungsanzeigen) fallen nicht auf.
|
||||
- **Maßnahme:** `tsc --noEmit` und ESLint als echte Skripte, Komponententests für kritische Pfade.
|
||||
|
||||
### FLAW-20: Dateigröße und Duplikate
|
||||
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
|
||||
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
|
||||
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
|
||||
|
||||
### FLAW-21: Lizenzdatei fehlt
|
||||
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
|
||||
|
||||
### FLAW-22: Netzwerk-Details
|
||||
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
|
||||
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
|
||||
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
|
||||
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
|
||||
|
||||
### FLAW-23: SQLite-Schreibengpass
|
||||
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
|
||||
|
||||
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
|
||||
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
|
||||
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
|
||||
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
|
||||
### FLAW-01: [GELÖST / IMPLEMENTIERT] Granulare Outliner-Synchronisation & Multi-User Kollaboration
|
||||
- **Status:** **Gelöst via In-Memory DocRooms, Echo-Schutz & Debounced Persistence**
|
||||
- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/docroom.go`, `internal/storage/sqlite.go`, `frontend/src/lib/useDocumentSync.ts`, `frontend/src/features/workspace/OutlinerBlock.tsx`
|
||||
- **Ursache (Historisch):** MindSift nutzte früher pessimistisches Dokumentensperren (Lease-Locks mit Heartbeats). Bei parallelem Bearbeiten blockierten sich Nutzer gegenseitig; unausgereifte Sync-Versuche führten zu Selbstechos und Cursor-Jumps.
|
||||
- **Implementierte Lösung:**
|
||||
1. **In-Memory DocRooms:** Granularer WebSocket-Raum pro Dokument (`/api/v1/ws/docs/:id`) mit Mutex-geschütztem Block-State im Go-Backend.
|
||||
2. **Strikter Echo-Schutz per `clientId`:** Jede Verbindung besitzt eine eindeutige `clientId`. Der Hub filtert den Sender heraus und leitet Events nur an Peers weiter.
|
||||
3. **Live Presence & Ephemere Cursors:** Cursorposition und Textselektionen (`cursor_moved`) werden flüchtig an Peers gestreamt, ohne SQLite zu belasten.
|
||||
4. **Fokus- & Eingabeschutz (`OutlinerBlock.tsx`):** Remote-Updates werden nur blockiert, wenn `document.hasFocus()` vorliegt. Bei `window.onblur` und `input.onblur` wird sofort (0ms) geflusht; Callbacks werden über `useRef` geführt (keine Stale Closures).
|
||||
5. **1.5s Inaktivitäts-Debounce-Flush:** Nach 1,5s Inaktivität sichert der Raum persistent in SQLite (`blocks`, Regex `#tags` in `block_tags` und kompiliertes Markdown in `documents.markdown_cache`).
|
||||
6. **Vollständige Eliminierung von Locks:** Alle blockierenden Sperren wurden restlos entfernt. Mehrere Nutzer können zeitgleich schreiben.
|
||||
|
||||
---
|
||||
|
||||
## Aus der früheren Fassung entfernt
|
||||
### FLAW-02: [OBSOLET / GELÖST] Dual-Storage Drift (SQLite vs. Dateisystem)
|
||||
- **Status:** **Entkräftet & Behoben (Pure Database Architecture)**
|
||||
- **Klarstellung:** In früheren Entwürfen wurde angedacht, Notizen zusätzlich als Markdown-Dateien auf dem Dateisystem zu spiegeln. MindSift speichert Notizen und Blöcke jedoch **zu 100% relational in SQLite** (`documents` und `document_blocks`).
|
||||
- **Verifikation:**
|
||||
- Es existieren **keine** Markdown-Dateien auf der Festplatte.
|
||||
- Das Go-Backend schreibt keine Dateien in ein Notizverzeichnis.
|
||||
- Unnötige Verzeichniserstellungen (`docDir`/`workspace`) wurden aus `internal/storage/sqlite.go` vollständig entfernt.
|
||||
- **Ergebnis:** Es gibt keinen Dual-Storage und somit keinen File-Drift oder externe Dateikonflikte. Notizen verhalten sich vollständig transaktional und konsistent innerhalb der SQLite-Datenbank.
|
||||
|
||||
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
|
||||
---
|
||||
|
||||
### FLAW-03: [GELÖST / IMPLEMENTIERT] Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback
|
||||
- **Status:** **Gelöst & Vollständig Implementiert (Dual-Transport SSE Fallback & Proxy-Resilienz)**
|
||||
- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`reverse-proxy.md`](./reverse-proxy.md), [`internal/ws/hub.go`](../internal/ws/hub.go), [`internal/api/server.go`](../internal/api/server.go), [`frontend/src/lib/api.ts`](../frontend/src/lib/api.ts)
|
||||
- **Problemursache:** Corporate Proxies (z.B. Zscaler, BlueCoat, strikte Firewalls) oder unvollständig konfigurierte Reverse-Proxies verwerfen den HTTP-Upgrade-Header `Upgrade: websocket` (`NS_ERROR_CONNECTION_REFUSED`, Status 1006 / 1002). Früher fiel der Client dabei auf 2-Sekunden-Polling zurück, was zu Verzögerungen und Last führte.
|
||||
- **Implementierte Lösung:**
|
||||
1. **Unidirektionaler Server-Sent Events (SSE) Fallback:**
|
||||
- Neuer Streaming-Endpunkt `GET /api/v1/events` im Go-Backend mit `Content-Type: text/event-stream`, `X-Accel-Buffering: no` und 15s Heartbeat-Pings (`: ping\n\n`).
|
||||
- Standard HTTPS Ports (443/80), wodurch kein Proxy den Datenstrom blockieren kann.
|
||||
2. **Hub-Multiplexing:**
|
||||
- `ws.Hub` verwaltet WebSocket- und SSE-Clients parallel in gemeinsamen Räumen (`rooms`, `userRooms`, `allClients`).
|
||||
- Sämtliche Events (`BroadcastToAll`, `BroadcastToUser`, `BroadcastRoom`) werden transparent an beide Transportarten verteilt.
|
||||
3. **REST-Parität für Outbound CRDT & Awareness:**
|
||||
- `PUT /api/v1/documents/:id/crdt`: Akzeptiert Delta-Updates (`update`) und broadcastet sie an alle Peers.
|
||||
- `POST /api/v1/documents/:id/awareness` und `DELETE /api/v1/documents/:id/awareness`: Übertragen Blockfokus und Cursor ohne WebSocket.
|
||||
- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients dynamisches Room-Joining.
|
||||
4. **Frontend Realtime Transport Manager (`frontend/src/lib/api.ts`):**
|
||||
- Automatischer 3,5s-Watchdog: Schlägt das WebSocket-Upgrade fehl oder wird Code 1006/1002 gemeldet, wechselt MindSift nahtlos auf `EventSource('/api/v1/events')`.
|
||||
- Grüner/türkiser Statusindikator im Header zeigt `Live (WebSocket)` bzw. `Live (Server-Sent Events / Proxy-Stream)`.
|
||||
5. **Reverse-Proxy-Handbuch:**
|
||||
- Vollständige Produktions-Konfigurationen für Nginx, Traefik, Caddy, Cloudflare und Apache unter [`reverse-proxy.md`](./reverse-proxy.md).
|
||||
|
||||
---
|
||||
|
||||
### FLAW-04: Volltextsuche via `LIKE '%query%'`
|
||||
- **Betroffene Komponenten:** [`database.md`](./database.md), `internal/api/search.go`, `internal/api/thoughts.go`
|
||||
- **Ursache:** Suchabfragen über Gedanken, Notizblöcke und Aufgaben nutzen aktuell einfaches SQL:
|
||||
```sql
|
||||
WHERE content LIKE '%' || ? || '%'
|
||||
```
|
||||
- **Folgen:** `LIKE '%...'` kann B-Tree-Indizes prinzipbedingt nicht nutzen und erzwingt einen sequentiellen Full Table Scan. Bei Datenbanken mit Zehntausenden Blöcken führt dies zu CPU-Spitzen und trägen Suchreaktionen.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Einrichten einer virtuellen SQLite FTS5-Tabelle (`document_blocks_fts`) mit Tokenizer (`unicode61` oder `trigram`).
|
||||
2. Automatische Synchronisierung über SQLite Trigger (`AFTER INSERT`, `AFTER UPDATE`, `AFTER DELETE`).
|
||||
|
||||
---
|
||||
|
||||
### FLAW-05: Fehlende Virtualisierung im Outliner-Baum
|
||||
- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/features/workspace/WorkspaceView.tsx`
|
||||
- **Ursache:** Jeder Block im Notizbaum wird als eigenes DOM-Element gerendert. Enthält ein Dokument 500 bis 2000 Blöcke (z. B. lange Projektjournale oder Besprechungsprotokolle), befinden sich tausende verschachtelte `div`- und `contenteditable`-Elemente gleichzeitig im DOM.
|
||||
- **Folgen:** Auf älteren Smartphones oder bei schnellem Tippen kommt es zu spürbaren Input-Latenzen und Ruckeln beim Scrollen.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Implementierung einer virtuellen Fensterung (Virtual Windowing) für Blöcke außerhalb des Sichtbereichs.
|
||||
2. Automatisches Einklappen (*Collapsing*) tiefer Hierarchieebenen beim initialen Laden langer Dokumente.
|
||||
|
||||
---
|
||||
|
||||
### FLAW-06: SQLite Concurrency & Single-Writer-Bottleneck
|
||||
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/storage/sqlite.go`
|
||||
- **Ursache:** Obwohl SQLite mit `PRAGMA journal_mode = WAL;` parallele Leser erlaubt, kann zu jedem Zeitpunkt immer nur **ein einziger Thread** schreiben. Bei intensiver gleichzeitiger Nutzung (z. B. 20 Teammitglieder tippen gleichzeitig in Listen und Notizen, während im Hintergrund Webhook-Events einlaufen) können Schreib-Transaktionen aufeinander warten.
|
||||
- **Folgen:** Risiko von `database is locked (SQLITE_BUSY)`-Fehlern, wenn der Busy-Timeout überschritten wird.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Setzen von `PRAGMA busy_timeout = 5000;` (5 Sekunden Wartezeit vor Fehlerwurf).
|
||||
2. Kapselung aller Schreibzugriffe über einen dedizierten Go-Channel / Single-Worker-Pool oder Migration auf PostgreSQL bei größeren Installationen.
|
||||
|
||||
---
|
||||
|
||||
### FLAW-07: Fehlende Dead-Letter-Queue für Benachrichtigungen
|
||||
- **Betroffene Komponenten:** [`modules/tasks.md`](./modules/tasks.md), `internal/scheduler/alarm_ticker.go`
|
||||
- **Ursache:** Der Alarm-Ticker überprüft minütlich fällige Aufgaben und sendet Webhooks bzw. E-Mails. Schlägt der HTTP-Post an den externen Webhook fehl (z. B. Zielserver vorübergehend offline), wird der Fehler lediglich ins Log geschrieben.
|
||||
- **Folgen:** Der Alarm wird nicht erneut versucht und die Benachrichtigung geht dauerhaft verloren.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Einführung einer Tabelle `notification_outbox` mit Spalten `retry_count`, `next_retry_at`, `status`.
|
||||
2. Exponentieller Backoff (1m, 5m, 15m, 1h) und Status-Markierung (*failed*, *delivered*).
|
||||
|
||||
---
|
||||
|
||||
### FLAW-08: [GELÖST / IMPLEMENTIERT] JWT-Sicherheit, Token-Blacklisting & Token-Versionierung
|
||||
- **Status:** **Gelöst via `token_version`-Spalte in `users`, `revoked_tokens`-Blacklist und serverseitige Invalidierungs-Endpunkte**
|
||||
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/api/server.go`, `internal/auth/auth.go`, `internal/storage/sqlite.go`, `frontend/src/lib/api.ts`
|
||||
- **Ursache (Historisch):** Nach dem Login wurde ein statisches JWT ausgestellt, das lediglich kryptografisch signiert war. Bei Passwortänderungen, Rechteänderungen oder Konto-Deaktivierung blieben bereits ausgestellte Tokens bis zum Ablaufdatum gültig.
|
||||
- **Implementierte Lösung:**
|
||||
1. **`token_version`-Spalte in `users`:** Jedes ausgestellte JWT führt die `tokenVersion` des Benutzers im Claim. Bei einer Passwortänderung (`UpdateUserPassword`), Rechterückstufung oder Deaktivierung wird `token_version = token_version + 1` atomar hochgezählt. Alle alten Tokens auf beliebigen Geräten werden sofort mit `401 Unauthorized` abgewiesen.
|
||||
2. **Tabelle `revoked_tokens` für Einzelsitzungen:** Beim Klick auf Logout (`POST /api/v1/auth/logout`) wird das spezifische JWT via SHA-256-Hash in die Sperrliste eingetragen, bis sein reguläres Ablaufdatum erreicht ist.
|
||||
3. **"Alle Sitzungen beenden" (`POST /api/v1/auth/revoke-all`):** Ermöglicht es dem Benutzer in den Einstellungen ("Profil & Sicherheit") oder einem Administrator, mit einem Klick alle aktiven Sitzungen auf sämtlichen Browsern und Mobilgeräten zu terminieren.
|
||||
4. **Konsistente Prüfung bei WebSockets & SSE:** Auch bei Echtzeit-Verbindungen (`/api/v1/ws`, `/api/v1/ws/docs/:id`, `/api/v1/events`) wird der Benutzerstatus, die Sperrliste und die aktuelle `token_version` überprüft. Outdated oder gesperrte Clients werden sofort abgewiesen.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Verwandte Dokumente
|
||||
- 🏗️ [Gesamtarchitektur](architecture.md)
|
||||
- 💾 [Datenbank-Design & Tabellenschemata](database.md)
|
||||
- 🔌 [WebSocket & Live-Protokoll](api/websocket.md)
|
||||
- 🧠 [Workspace-Modul (Outliner)](modules/workspace.md)
|
||||
- 🛡️ [Rechte & Freigabesystem](sharing-and-permissions.md)
|
||||
+57
-14
@@ -1,19 +1,62 @@
|
||||
# Modul: Geteilte Listen
|
||||
# 🛒 Kernmodul 4: Geteilte Listen & Kollaboration
|
||||
|
||||
Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsView.tsx](../../frontend/src/features/lists/SharedListsView.tsx); Backend: `*List*`-Handler in [server.go](../../internal/api/server.go), `Store.CreateList` und Verwandte.
|
||||
Das Modul für geteilte Listen ([`SharedListsView.tsx`](../../frontend/src/features/lists/SharedListsView.tsx)) ist auf **Echtzeit-Kollaboration im Alltag** ausgelegt (z.B. gemeinsamer Wocheneinkauf im Supermarkt, Packlisten für Reisen, Inventarlisten oder schnelle Checklisten).
|
||||
|
||||
## Datenmodell
|
||||
---
|
||||
|
||||
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
|
||||
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`.
|
||||
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
|
||||
## 🛍️ 1. Listen-Typen & Kategorien
|
||||
|
||||
## Funktionen
|
||||
Jede Liste besitzt einen Typ, ein Icon und eine Farb-Codierung:
|
||||
|
||||
- **Kategorien per Hashtag:** `Hafermilch #Kühlregal` landet in der Kategorie „Kühlregal“. Kategorien werden alphabetisch als Abschnitte angezeigt; die Eingabe schlägt vorhandene Hashtags vor.
|
||||
- **Menge:** Feld `quantity` (z. B. `2x`, `500g`).
|
||||
- **Drag & Drop** ordnet Einträge um; in eine andere Kategorie gezogen, wird der Hashtag im Text angepasst (`items/reorder` mit `category` und `content`).
|
||||
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
|
||||
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
|
||||
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
|
||||
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
|
||||
| Listen-Typ | Icon | Einsatzzweck | Typische Kategorien |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `shopping` | 🛒 | Supermarkt- & Lebensmitteleinkauf | Obst & Gemüse, Kühlregal, Vorrat, Drogerie |
|
||||
| `packing` | 🎒 | Urlaubs- & Reise-Packlisten | Kleidung, Elektronik, Dokumente, Kosmetik |
|
||||
| `inventory` | 📦 | Vorratskammer, Keller, Homelab-Hardware | Regal 1, Gefrierschrank, Server-Rack |
|
||||
| `todo` | 📝 | Einfache Gruppen-Checklisten | Heute, Später, Ideen |
|
||||
|
||||
### Automatische Kategorisierung & Mengenangaben
|
||||
- Einträge können mit einer Mengenangabe (z.B. `3x`, `500g`, `1 Kasten`) versehen werden.
|
||||
- Die intelligente Eingabe erkennt Einheiten automatisch oder trennt Mengen über Eingabefelder.
|
||||
- Einträge werden übersichtlich nach Kategorien gruppiert. Jede Kategorie kann zur besseren Übersicht eingeklappt werden.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 2. Echtzeit-Abhaken (Multi-User Live Sync)
|
||||
|
||||
Wenn mehrere Familienmitglieder oder Teammitglieder gleichzeitig im Supermarkt unterwegs sind, synchronisiert MindSift jeden Schritt in Echtzeit über WebSockets:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Alice as Alice (im Markt)
|
||||
participant WS as WebSocket Hub
|
||||
actor Bob as Bob (zu Hause)
|
||||
|
||||
Alice->>WS: PATCH /items/123 (isCompleted: true)
|
||||
WS->>Bob: Event: ITEM_TOGGLED (itemId: "123", completedBy: "Alice")
|
||||
Note over Bob: Eintrag wird sofort durchgestrichen<br/>Grünes Badge "von Alice" erscheint
|
||||
```
|
||||
|
||||
- **Transparenz:** Beim Abhaken speichert MindSift `completed_by_name`. Ein dezentes grünes Pillen-Badge zeigt an, wer den Artikel eingepackt hat.
|
||||
- **Erledigte Einträge:** Abgehakte Artikel wandern nach unten in einen einklappbaren Bereich „Erledigt".
|
||||
- **Liste leeren:** Ein Klick auf „Erledigte entfernen" löscht alle abgehakten Einträge (`LIST_CLEARED`) für alle Teilnehmer.
|
||||
|
||||
---
|
||||
|
||||
## 🖐️ 3. Drag & Drop Umsortierung
|
||||
|
||||
Innerhalb einer Liste können Einträge frei per Drag & Drop sortiert werden:
|
||||
1. **Innerhalb einer Kategorie:** Ändert die Reihenfolge (`sort_order`).
|
||||
2. **Kategorie-Übergreifend:** Zieht der Benutzer einen Eintrag in eine andere Kategorie (z.B. von „Kühlregal" nach „Obst & Gemüse"), aktualisiert das Frontend optimistisch die Kategorie und sendet das Update ans Backend.
|
||||
|
||||
---
|
||||
|
||||
## 👥 4. Freigaben & Berechtigungen
|
||||
|
||||
Listen können über den einheitlichen Freigabe-Dialog ([`UnifiedShareModal.tsx`](../../frontend/src/components/UnifiedShareModal.tsx)) geteilt werden:
|
||||
- **Freigabe für Teams:** Alle aktuellen und zukünftigen Mitglieder der gewählten Gruppe erhalten automatisch Zugriff.
|
||||
- **Freigabe für Einzelbenutzer:** Gezielter Zugriff für einzelne Personen.
|
||||
- **Rollen:**
|
||||
- `editor`: Darf Einträge hinzufügen, bearbeiten, abhaken und löschen.
|
||||
- `viewer`: Nur Lesezugriff (Einträge ansehen, aber nicht verändern).
|
||||
+74
-26
@@ -1,38 +1,86 @@
|
||||
# Modul: Aufgaben, Kanban & Alarme
|
||||
# 📋 Kernmodul 2: Aufgaben & Kanban-System
|
||||
|
||||
Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDetailModal.tsx](../../frontend/src/features/triage/TaskDetailModal.tsx), [ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx); Backend: `handleUpdateThought`, `handleReorderTasks`, `*TaskList*`-Handler, `startAlarmTicker`.
|
||||
Das Aufgaben-Modul ([`TasksView.tsx`](../../frontend/src/features/triage/TasksView.tsx)) bietet eine flexible Aufgabenverwaltung mit frei konfigurierbaren Listen, Kanban-Boards, Subtask-Checklisten und einem automatisierten Multi-Channel Alarm-System.
|
||||
|
||||
## Datenmodell
|
||||
---
|
||||
|
||||
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich.
|
||||
## 🗂️ 1. Datenmodell & Listen-Hierarchie
|
||||
|
||||
## Ansichten
|
||||
Aufgaben basieren technisch auf der Tabelle **`thoughts`** in SQLite. Ein Eintrag gilt als Aufgabe, wenn `status = 'kept'` oder `(status = 'converted' AND converted_type = 'task')` zutrifft.
|
||||
|
||||
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
||||
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
||||
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
||||
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
||||
```
|
||||
Benutzer (users)
|
||||
└── Aufgabenlisten (task_lists)
|
||||
├── Eigene Listen (z.B. "Privat", "Homelab")
|
||||
└── Mit Teams geteilte Listen (task_list_shares)
|
||||
└── Aufgaben (thoughts)
|
||||
├── Kanban-Status (todo, in_progress, done)
|
||||
├── Priorität (low, normal, high, urgent)
|
||||
├── Fälligkeitsdatum (dueDate)
|
||||
├── Notizen (notes)
|
||||
└── Subtasks (JSON Array von Teilaufgaben)
|
||||
```
|
||||
|
||||
## Aufgabenlisten und Freigabe
|
||||
### Listen-Sharing & Avatar-Stack
|
||||
Aufgabenlisten können für Gruppen/Teams freigegeben werden. Ein Avatar-Stack im Listen-Header und in der Tab-Leiste visualisiert alle Mitglieder, die Zugriff auf die jeweilige Liste haben.
|
||||
|
||||
- Jede Liste hat Eigentümer, Titel, Symbol, Farbe und eigene Kanban-Spalten.
|
||||
- Freigabe an Gruppen oder Benutzer über den Dialog `UnifiedShareModal` (Stufe pro Freigabe):
|
||||
- **Bearbeiter:** darf Aufgaben anlegen, ändern, abhaken, verschieben, löschen sowie Liste umbenennen und Spalten ändern.
|
||||
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
|
||||
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
|
||||
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
|
||||
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
|
||||
---
|
||||
|
||||
## Alarme
|
||||
## 🖥️ 2. Ansichten: Liste vs. Kanban-Board
|
||||
|
||||
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
|
||||
Der Benutzer kann jederzeit nahtlos zwischen zwei Ansichten umschalten:
|
||||
|
||||
| Kanal | Ziel | Konfiguration |
|
||||
|---|---|---|
|
||||
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) |
|
||||
| Discord | Webhook-URL | Benutzereinstellungen |
|
||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||
### 1. Kompakte Listenansicht
|
||||
- Optimiert für schnelles Abarbeiten und Tastatursteuerung.
|
||||
- Zeigt Checkbox, Titel, Prioritäts-Badge, Herkunfts-Pille (`Webhook`, `Diktat`), Fälligkeitsdatum und Subtask-Zähler (`2/5`).
|
||||
- 1-Klick-Prioritäts-Wechsel: Klick auf ein Badge rotiert durch die Prioritäten.
|
||||
|
||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
||||
### 2. Kanban-Board (Drag & Drop)
|
||||
- Standardmäßig 3 Spalten: **Zu erledigen** (`todo`), **In Arbeit** (`in_progress`) und **Erledigt** (`done`).
|
||||
- **Freie Spalten-Konfiguration:** Über das Spalten-Menü können Spalten umbenannt, farblich markiert oder neue Spalten hinzugefügt werden.
|
||||
- **Drag & Drop:** Aufgaben können per HTML5 Drag & Drop zwischen Spalten verschoben werden. Das Backend speichert die relative Position (`sort_order`) und den neuen `kanban_status` automatisch.
|
||||
|
||||
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
|
||||
---
|
||||
|
||||
## 🎯 3. Prioritäten & Subtasks
|
||||
|
||||
### 4-Stufen-Prioritätensystem
|
||||
Aufgaben unterstützen vier einheitliche Prioritätsstufen:
|
||||
- **Niedrig (`low`):** Dezent grau hinterlegt.
|
||||
- **Normal (`normal`):** Akzentfarbe.
|
||||
- **Wichtig (`high`):** Gelb hervorgehoben mit Warndreieck (`⚠️ Wichtig`).
|
||||
- **Dringend (`urgent`):** Rot pulsierend mit Flammen-Icon (`🔥 Dringend`).
|
||||
|
||||
### Subtasks (Teilaufgaben)
|
||||
Jede Aufgabe kann eine hierarchische Checkliste von Subtasks enthalten:
|
||||
- Gespeichert als JSON in der Spalte `subtasks`.
|
||||
- Fortschrittsanzeige im Kartenkopf (z.B. `3/5 erledigt`).
|
||||
- Einzelnes Abhaken synchronisiert direkt in die Datenbank.
|
||||
|
||||
---
|
||||
|
||||
## ⏰ 4. Multi-Channel Alarm-Scheduler
|
||||
|
||||
MindSift enthält einen serverseitigen Hintergrund-Ticker in Go ([`internal/api/server.go`](../../internal/api/server.go)), der fällige Aufgaben proaktiv an externe Kanäle meldet.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Ticker["Go Background Ticker (alle 30s)"] --> Query["SELECT thoughts WHERE due_date <= now + Vorwarnzeit AND alarm_sent = 0"]
|
||||
Query --> CheckChannels{"Welche Kanäle sind beim User aktiv?"}
|
||||
|
||||
CheckChannels -->|email| SendSMTP["E-Mail via SMTP versenden"]
|
||||
CheckChannels -->|discord| SendDiscord["Discord Webhook POST"]
|
||||
CheckChannels -->|webhook| SendCustom["Custom HTTP POST Webhook"]
|
||||
|
||||
SendSMTP --> MarkSent["UPDATE thoughts SET alarm_sent = 1"]
|
||||
SendDiscord --> MarkSent
|
||||
SendCustom --> MarkSent
|
||||
```
|
||||
|
||||
### Alarm-Konfiguration
|
||||
1. **Vorwarnzeit:** Einstellbar in Minuten (z.B. 15, 30, 60 Minuten vor Fälligkeit).
|
||||
2. **Kanal-Multi-Select:** Benutzer können beliebig viele Kanäle parallel aktivieren:
|
||||
- **E-Mail:** Versand über den in den Admin-Einstellungen hinterlegten SMTP-Server.
|
||||
- **Discord:** Benachrichtigung mit formatierter Embed-Karte in einen Discord-Kanal.
|
||||
- **Eigener Webhook:** Beliebige HTTP POST JSON Endpunkte (z.B. Gotify, Ntfy, Home Assistant).
|
||||
3. **Schutz vor Doppel-Alarmen:** Sobald ein Alarm abgesetzt wurde, wird `alarm_sent = 1` gesetzt. Wird das Datum der Aufgabe später geändert, setzt der Server `alarm_sent = 0` automatisch zurück.
|
||||
+77
-25
@@ -1,36 +1,88 @@
|
||||
# Modul: Schnellerfassung & Triage
|
||||
# 📥 Kernmodul 1: Triage & Quick-Dump Engine
|
||||
|
||||
Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [InstantCaptureBar.tsx](../../frontend/src/features/capture/InstantCaptureBar.tsx), [TriageDeck.tsx](../../frontend/src/features/triage/TriageDeck.tsx), Backend `handleCaptureInbox`, `handleTriageAction` und `Store.UpdateThoughtTriage`.
|
||||
Das Triage-Modul von MindSift basiert auf dem *Getting Things Done (GTD)*-Prinzip. Es trennt die **Erfassung von Gedanken** strikt von der **Entscheidung, was damit geschehen soll**.
|
||||
|
||||
## Erfassung
|
||||
---
|
||||
|
||||
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
|
||||
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
|
||||
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API des Browsers; Quelle `voice` fügt den Tag `#diktat` an.
|
||||
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
|
||||
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
|
||||
## 💡 1. Das 2-Phasen-Konzept
|
||||
|
||||
## Triage-Deck
|
||||
1. **Phase 1: Schnelles Erfassen (Instant Dump)**
|
||||
Ein Gedanke, ein Anruf, eine Idee oder ein Server-Alarm wird ohne Reibung erfasst. Der Benutzer muss in diesem Moment weder ein Projekt auswählen, noch Fälligkeitsdaten eingeben oder Ordner durchsuchen.
|
||||
2. **Phase 2: Fokussierte Triage (Inbox Zero)**
|
||||
Zu einem späteren Zeitpunkt (z.B. morgens oder beim Feierabend) sichtet der Benutzer den Stapel im Triage-Deck Karte für Karte und trifft für jeden Eintrag eine eindeutige Entscheidung.
|
||||
|
||||
Der Posteingang (`status = inbox`) wird Karte für Karte abgearbeitet.
|
||||
---
|
||||
|
||||
| Aktion | Desktop | Mobil | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| Verwerfen | `H` oder `←` | Wischen links | `status = discarded` (Papierkorb) |
|
||||
| Zur Aufgabe | `L` oder `→` | Wischen rechts | `kept`/`converted` als Aufgabe |
|
||||
| Aufgabe mit Termin | `T` | Aktionsmenü | wie oben, mit Fälligkeit |
|
||||
| In Notiz wandeln | `D` | Aktionsmenü | neues Dokument, Gedanke wird `converted` (`document`) |
|
||||
| Später | Leertaste | Aktionsmenü | Karte ans Stapelende |
|
||||
| Menü schließen | `Esc` | – | – |
|
||||
## 🔄 2. Triage State Machine
|
||||
|
||||
Die Umwandlung in eine Notiz übernimmt Titel, Erledigt-Status, Notizen, Unteraufgaben sowie Fälligkeit und Priorität als eingerückte Blöcke. Eine Umwandlung in einen Listeneintrag legt einen Eintrag in der gewählten Liste an und benachrichtigt deren Teilnehmer.
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Capture: Gedanke erfasst (Desktop / Mobile / Webhook)
|
||||
|
||||
Capture --> Inbox: Standard (status: 'inbox')
|
||||
Capture --> Task: Direkt als Aufgabe (target: 'task')
|
||||
Capture --> Document: Direkt als Notiz (target: 'document')
|
||||
Capture --> ListItem: Direkt auf Liste (target: 'list_item')
|
||||
|
||||
## Zustände eines Gedankens
|
||||
state Inbox {
|
||||
[*] --> ActiveCard: Oben auf Stapel
|
||||
ActiveCard --> Skipped: Leertaste / Später (Ans Ende)
|
||||
Skipped --> ActiveCard
|
||||
}
|
||||
|
||||
```
|
||||
inbox ──keep──▶ kept ──convert──▶ converted (task | document | list_item)
|
||||
│ │
|
||||
└─discard─▶ discarded ──restore──▶ inbox (Papierkorb leeren: DELETE /thoughts/discarded/purge)
|
||||
ActiveCard --> Discarded: Swipe links / H (Wegwerfen)
|
||||
ActiveCard --> Task: Swipe rechts / L (Als Aufgabe)
|
||||
ActiveCard --> TaskDated: T (Mit Fälligkeitsdatum)
|
||||
ActiveCard --> Document: D (In Notiz umwandeln)
|
||||
ActiveCard --> ListItem: Auf Liste schieben
|
||||
|
||||
Discarded --> Trash: status: 'discarded'
|
||||
Task --> TasksModule: status: 'kept', converted: 'task'
|
||||
TaskDated --> TasksModule: status: 'kept', converted: 'task', dueDate
|
||||
Document --> WorkspaceModule: status: 'converted', converted: 'document'
|
||||
ListItem --> ListsModule: status: 'converted', converted: 'list_item'
|
||||
```
|
||||
|
||||
Ein Gedanke mit `status = kept` zählt bereits als Aufgabe und erscheint in der Aufgabenansicht.
|
||||
---
|
||||
|
||||
## 🖐️ 3. Steuerung & Gesten (Mobile vs. Desktop)
|
||||
|
||||
### Desktop-Bedienung (Tastatur-First)
|
||||
- `C` oder `/`: Fokussiert sofort die Eingabeleiste oben im Header.
|
||||
- `Enter`: Speichert den Gedanken sofort im Deck.
|
||||
- `←` oder `H`: Aktive Karte wegwerfen (`discard`).
|
||||
- `→` oder `L`: Aktive Karte sofort zur Aufgabe machen (`task`).
|
||||
- `T`: Datumsauswahl für Termin-Aufgabe öffnen.
|
||||
- `D`: Aktive Karte in eine vollwertige Workspace-Notiz umwandeln.
|
||||
- `Leertaste`: Karte überspringen und ans Ende des Stapels schieben („Später").
|
||||
|
||||
### Mobile Bedienung (Touch & Swipe)
|
||||
- **Mittlerer Plus-Button (`+`):** Öffnet das design-angeglichene Bottom-Sheet mit Spracheingabe, 4 Prioritäts-Buttons (`Niedrig`, `Normal`, `Wichtig`, `Dringend`) und Fälligkeitsdatum.
|
||||
- **Wischgeste nach links (Swipe Left):** Roter Indikator erscheint, Karte wird verworfen.
|
||||
- **Wischgeste nach rechts (Swipe Right):** Grüner Indikator erscheint, Karte wird sofort zur Aufgabe.
|
||||
- **Long-Press (Langes Drücken auf Karte):** Öffnet das Schnell-Aktions-Menü (Auf Liste, Mit Termin, Als Notiz, Später).
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ 4. Spracheingabe (Speech-to-Text)
|
||||
|
||||
In der Desktop-Schnelleingabeleiste ([`InstantCaptureBar.tsx`](../../frontend/src/features/capture/InstantCaptureBar.tsx)) sowie im mobilen Triage-Modal ([`App.tsx`](../../frontend/src/App.tsx)) ist ein nativer Browser-Sprachassistent integriert:
|
||||
- Verwendet die standardisierte `SpeechRecognition` bzw. `webkitSpeechRecognition` API.
|
||||
- Erkennt Sprache auf Deutsch (`lang: 'de-DE'`).
|
||||
- Live-Transkription: Gesprochener Text wird in Echtzeit in das Textfeld eingefügt.
|
||||
- Haptisches Feedback: Vibrieren beim Starten und Stoppen der Aufnahme.
|
||||
- Markiert erfasste Gedanken automatisch mit dem Herkunfts-Attribut `source: 'voice'` (Diktat-Badge).
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 5. Webhook-Ingestion & Smarthome-Pille
|
||||
|
||||
MindSift kann externe Alarme (Home Assistant, Zigbee2MQTT, n8n, Proxmox, Monitoring) automatisch ins Triage-Deck einspeisen:
|
||||
|
||||
### Herkunftserkennung
|
||||
1. Wird ein Gedanke über `/api/v1/inbox` mit `X-API-Key` oder über `/api/v1/webhooks/ingest` eingeliefert, setzt das Backend automatisch `source = "webhook"`.
|
||||
2. Im Frontend visualisiert die Triage-Karte eine dezente Webhook-Pille (`<Webhook className="w-3 h-3 text-accent" /> Webhook`).
|
||||
3. Diese Kennzeichnung bleibt auch erhalten, wenn die Karte später zu einer Aufgabe umgewandelt wird.
|
||||
|
||||
### Live-WebSocket Update
|
||||
Nach erfolgreichem Ingest sendet der Server sofort ein `TASK_CREATED` Event über den WebSocket-Hub. Das geöffnete Triage-Deck fügt die neue Alarm-Karte sofort oben in den Stapel ein – ohne dass der Benutzer die Seite neu laden muss.
|
||||
+93
-28
@@ -1,39 +1,104 @@
|
||||
# Modul: Workspace (Outliner-Notizen)
|
||||
# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage
|
||||
|
||||
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, `Store.PatchDocumentBlocks`.
|
||||
Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Wiki**, **Live-Kollaboration** und einem **globalen To-Do Rollup Dashboard**.
|
||||
|
||||
## Datenhaltung
|
||||
---
|
||||
|
||||
- Eine Notiz ist ein Datensatz in `documents` plus geordnete Zeilen in `blocks` (`position` als Kommazahl, `level` für die Einrückung). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
|
||||
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
|
||||
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
|
||||
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||
## 🌳 1. Die Logseq-Outliner-Engine
|
||||
|
||||
## Editor
|
||||
Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace auf einer **flachen, relationalen Liste von Blöcken** (`blocks`-Tabelle in SQLite). Jeder Block ist ein gedanklicher Knotenpunkt mit einer flexiblen Einrückungsebene (`level: 0, 1, 2, ...`) und einer fortlaufenden Fließkommaposition (`position`):
|
||||
|
||||
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
|
||||
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
||||
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
||||
- **Speichern:** Der Editor speichert 800 ms nach der letzten Eingabe das gesamte Dokument (`PUT /documents/{id}`). Der schlanke Block-Endpunkt `PATCH /documents/{id}/blocks` wird derzeit vom To-Do-Rollup beim Abhaken genutzt.
|
||||
```
|
||||
• Projekt MindSift v2.0 (level 0)
|
||||
• Architektur-Entscheidung (level 1)
|
||||
- [x] Database-First Blocks implementieren (level 2)
|
||||
- [ ] Performance Benchmark mit 10.000 Blöcken (level 2)
|
||||
• Notizen aus dem Dev-Meeting (level 1)
|
||||
> Wichtig: Keine verschachtelten SQLite Queries ausführen! (level 2)
|
||||
```
|
||||
|
||||
## Explorer, Tags, To-Dos
|
||||
### Tastatur-Navigation & Shortcuts
|
||||
- `Enter`: Erstellt einen neuen Block auf derselben Hierarchieebene.
|
||||
- `Tab`: Rückt den aktuellen Block ein (wird Kindknoten des vorherigen Blocks).
|
||||
- `Shift + Tab`: Rückt den Block um eine Ebene aus (verringert die Tiefe).
|
||||
- `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks (z.B. für mehrzeilige Notizen oder Code).
|
||||
- `Backspace` (auf leerem Block): Löscht den Block sofort. Das Ausrücken erfolgt strikt und kontrolliert über `Shift + Tab`.
|
||||
- `/` (Slash-Commands): Öffnet das Umwandlungsmenü (`/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote`, `/divider`).
|
||||
- **Markdown-Tastatur-Kürzel:** Tippen von `[] ` oder `[ ] ` wandelt den Block sofort in ein Todo um; `# `, `## `, `### ` erzeugt Überschriften.
|
||||
|
||||
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`).
|
||||
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`).
|
||||
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
|
||||
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
|
||||
### Eingabestabilität & Zombie-Fokus-Schutz (`OutlinerBlock.tsx`)
|
||||
1. **Echter Fokus-Check:** Remote-Updates (`block_updated`) werden nur dann abgewehrt, wenn der Block tatsächlich aktiv im Betriebssystem editiert wird:
|
||||
`isFocusedRef.current === true && document.hasFocus()`.
|
||||
2. **Sofort-Flush bei Fensterwechsel:** Sobald das Browser-Fenster den Fokus verliert (`window.addEventListener('blur', ...)`), wird ein offener 150ms-Debounce sofort gecancelt und der Text augenblicklich per WebSocket übertragen.
|
||||
3. **Stale Closures eliminiert:** Alle Callbacks werden in `useRef` gehalten, sodass asynchrone Debounce-Timer garantiert die aktuellste Komponenten-Referenz verwenden.
|
||||
|
||||
## Freigabe
|
||||
### Zoom-In Hoisting (Fokus-Modus)
|
||||
Klickt der Benutzer auf den Bullet-Dot (`•`) eines Blocks, blendet der Editor alle übergeordneten und benachbarten Blöcke aus und zoomt **ausschließlich in diesen Ast** hinein:
|
||||
- Der gewählte Punkt wird zur prominenten Überschrift im Fokus-Modus.
|
||||
- Eine klickbare Breadcrumb-Leiste oben zeigt den Pfad an (z.B. `Workspace / Projekte / MindSift / Architektur`).
|
||||
- Ein Klick auf ein beliebiges Breadcrumb-Element zoomt sofort auf die jeweilige Ebene zurück.
|
||||
|
||||
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
|
||||
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
||||
- Änderungen erreichen offene Fenster über `DOC_BLOCKS_PATCHED`/`DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
||||
### Drag & Drop von Blöcken
|
||||
Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben, erkennt die Engine automatisch alle untergeordneten Kind-Blöcke (`isDescendantBlock`) und verschiebt den **kompletten Unterbaum** an die neue Zielposition.
|
||||
|
||||
## Grenzen
|
||||
---
|
||||
|
||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
||||
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||
- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)).
|
||||
## 🔗 2. Wiki-Links (`[[Link]]`), Web-Links (`/link`) & Hashtags
|
||||
|
||||
1. **Interne Wiki-Links:**
|
||||
- Text in doppelten eckigen Klammern (z.B. `[[Reiseplanung 2026]]`) wird automatisch als klickbare Notiz-Pille gerendert.
|
||||
- Klick darauf öffnet die verknüpfte Notiz direkt im Editor.
|
||||
2. **Slash-Befehl `/link` & Link-Modal:**
|
||||
- `/link` öffnet ein Modal mit zwei Modi:
|
||||
- **Notiz verknüpfen:** Durchsucht bestehende Notizen und fügt `[[Titel]]` ein.
|
||||
- **Web-Link:** Eingabe von Titel und URL, fügt Standard-Markdown `[Titel](url)` ein.
|
||||
3. **Hashtags (`#tag`):**
|
||||
- Hashtags im Fließtext werden automatisch indiziert (`block_tags`-Tabelle).
|
||||
- In der linken Leiste listet der Tag-Explorer alle verwendeten Schlagworte auf. Klick auf ein Tag filtert alle Dokumente und Blöcke, die dieses Tag enthalten.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Echtzeit-Kollaboration & Automatische Markdown-Kompilierung
|
||||
|
||||
MindSift setzt auf eine latenzfreie In-Memory Kollaborations-Architektur ([`internal/ws/docroom.go`](../../internal/ws/docroom.go)):
|
||||
|
||||
### Funktionsweise
|
||||
1. **In-Memory DocRoom pro geöffnetem Dokument:**
|
||||
- Hält den synchronen Zustand von `title` und `blocks[]` im RAM.
|
||||
- Granulare Mutations-Events (`block_updated`, `block_created`, `block_deleted`, `block_moved`, `title_updated`).
|
||||
- Ephemeres Presence-Tracking (`cursor_moved`, `client_left`) zeigt farbige Benutzer-Avatare im Header und Cursors am aktiven Block an.
|
||||
2. **Echo-Schutz:**
|
||||
- Jeder Client identifiziert sich mit einer eindeutigen `clientId`.
|
||||
- Der Server sendet Mutations-Events ausschließlich an andere Clients im selben Raum (`clientId != senderClientId`), um Cursor-Springen und Selbstechos zu verhindern.
|
||||
|
||||
### Automatische Markdown-Kompilierung & SQLite Debounce-Flush
|
||||
```
|
||||
Eingabe im OutlinerBlock
|
||||
│
|
||||
├──▶ WebSocket (block_updated Event)
|
||||
│ │
|
||||
│ ▼
|
||||
│ Go DocRoom: In-Memory Mutation & Broadcast an Peers
|
||||
│
|
||||
└──▶ 1.5s Inaktivitäts-Debounce (oder Raum-Exit)
|
||||
│
|
||||
▼
|
||||
SQLite Transaktion:
|
||||
1. Persistiert Blöcke in `blocks`
|
||||
2. Regex-Extraktion von #tags in `block_tags`
|
||||
3. Kompiliert Block-Hierarchie zu Standard-Markdown
|
||||
und speichert Cache in `documents.markdown_cache`
|
||||
```
|
||||
|
||||
1. **Kompiliertes Markdown:** Jeder Block wird gemäß seiner `level`-Tiefe mit 2 Leerzeichen pro Einrückungsebene als sauberes Markdown formatiert (`documents.markdown_cache`).
|
||||
2. **Pure Database Architecture:** Alle Daten liegen transaktional und sicher in SQLite. Es werden keine unkontrollierten Dateien auf der Festplatte abgelegt; Notizen können jederzeit per Klick als `.md`-Datei exportiert werden.
|
||||
|
||||
---
|
||||
|
||||
## 📊 4. Globaler To-Do Rollup Dashboard
|
||||
|
||||
Über den Tab **„Globale To-Dos"** bietet der Workspace eine aggregierte Übersicht aller im gesamten Wiki verteilten Aufgaben:
|
||||
- Scannt in Echtzeit alle unvollendeten `- [ ] ` Checkboxen über alle Ordner und Notizen hinweg.
|
||||
- Übersichtliche Gruppierung nach **Ordner ➔ Dokument ➔ Aufgabe**.
|
||||
- **Ordner-Filter:** Klick auf einen Ordner isoliert nur dessen Aufgaben für fokussiertes Arbeiten.
|
||||
- **2-Wege-Live-Sync:** Wird eine Checkbox im Rollup abgehakt, wird der Block in SQLite sofort auf `checked = 1` aktualisiert und via WebSocket an alle aktiven Clients gebroadcastet.
|
||||
@@ -8,9 +8,9 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
|
||||
|
||||
| Protokoll | Standard-Port | Benötigte Header & Einstellungen | Zweck |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
|
||||
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Yjs-CRDT-Synchronisation & Awareness |
|
||||
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
|
||||
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
|
||||
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Anhänge & CRDT-Zustandsblöcke |
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@ server {
|
||||
ssl_certificate /etc/letsencrypt/live/mindsift.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/mindsift.example.com/privkey.pem;
|
||||
|
||||
# Maximale Payload-Größe für große Notizen und Exporte
|
||||
# Maximale Payload-Größe für Backups und große CRDT-Zustände
|
||||
client_max_body_size 64M;
|
||||
|
||||
# MindSift Frontend & REST API
|
||||
@@ -132,7 +132,7 @@ Caddy unterstützt WebSockets und HTTP/2 Server-Sent Events von Haus aus vollst
|
||||
mindsift.example.com {
|
||||
encode gzip zstd
|
||||
|
||||
# Event-Stream (SSE) ohne Pufferung weiterleiten
|
||||
# Notizen, CRDT & Event-Stream ohne Pufferung weiterleiten
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
# Streaming für SSE optimieren
|
||||
flush_interval -1
|
||||
@@ -148,7 +148,7 @@ Wird MindSift hinter dem Cloudflare CDN betrieben:
|
||||
1. **WebSockets aktivieren:**
|
||||
- Navigiere in das Cloudflare Dashboard: **Network** > **WebSockets** > Schalter auf **ON** stellen.
|
||||
2. **Cloudflare Rocket Loader / Auto Minify:**
|
||||
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
|
||||
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe die Yjs-CRDT-Initialisierung stören können.
|
||||
3. **Cloudflare WAF / Bot Fight Mode:**
|
||||
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
||||
4. **SSE-Unterstützung:**
|
||||
|
||||
@@ -1,55 +1,93 @@
|
||||
# Teilen & Berechtigungen
|
||||
# 👥 Synchrones Teilen & Berechtigungskonzept
|
||||
|
||||
## Rollen
|
||||
MindSift bietet ein mehrstufiges Kollaborations- und Freigabesystem. Es gewährleistet strikte Datensouveränität und Privatsphäre, ermöglicht aber gleichzeitig reibungslose Zusammenarbeit in Teams, Familien oder Unternehmen.
|
||||
|
||||
| Ebene | Werte | Bedeutung |
|
||||
|---|---|---|
|
||||
| Server | `admin`, `member` | Admins verwalten Benutzer, Einladungen und Einstellungen (`/admin/*`). |
|
||||
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
|
||||
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
|
||||
---
|
||||
|
||||
## Berechtigungen je Ressource
|
||||
## 🛡️ 1. Globale Rollen & System-Isolation
|
||||
|
||||
| Ressource | Eigentümer | Bearbeiter | Leser | Freigabe an |
|
||||
|---|---|---|---|---|
|
||||
| **Aufgabenliste** (`task_list_shares.permission`) | alles, löschen, (un)teilen | Aufgaben anlegen/ändern/abhaken/verschieben/löschen, Liste umbenennen, Spalten ändern | nur ansehen | Benutzer, Gruppen |
|
||||
| **Geteilte Liste** (`list_shares.role`) | alles, löschen, (un)teilen | Einträge anlegen/ändern/abhaken/löschen | nur ansehen | Benutzer, Gruppen |
|
||||
| **Notiz** (`doc_shares`, `doc_team_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
|
||||
| **Geteilter Ordner** (`workspace_folder_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
|
||||
### Globale Rollen
|
||||
- **Server-Admin (`role = 'admin'`):** Vollzugriff auf System-Einstellungen, E-Mail-Konfiguration (SMTP), Benutzerverwaltung, Einladungscodes und System-Statistiken.
|
||||
- **Mitglied (`role = 'member'`):** Regulärer Benutzer; arbeitet in seinen eigenen Notizen, Aufgaben und freigegebenen Teams.
|
||||
|
||||
- **Notizen und Ordner sind absichtlich schreibgeschützt für Empfänger.** Das erzwingt eine Migration beim Start (`permission = 'read'`); Schreibpfade im Code sind tot ([FLAW-17](flaws.md)).
|
||||
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
|
||||
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
|
||||
- Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist.
|
||||
### System-Isolations-Modi (`sharing_mode`)
|
||||
Der Server-Admin legt global fest, wie Benutzer miteinander interagieren dürfen:
|
||||
|
||||
## Aufgabenlisten teilen (Details)
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ System-Isolations-Modi │
|
||||
├──────────────────────┬─────────────────────────┬───────────────────────┤
|
||||
│ strict_private │ teams_only │ open │
|
||||
│ (Strikt Isoliert) │ (Teams & Gruppen) │ (Offene Instanz) │
|
||||
│ │ [EMPFOHLENER STANDARD]│ │
|
||||
├──────────────────────┼─────────────────────────┼───────────────────────┤
|
||||
│ Kein Adressbuch │ Adressbuch zeigt nur │ Alle Benutzer sehen │
|
||||
│ Keine Freigaben │ gemeinsame Teammit- │ alle anderen Konten │
|
||||
│ Autarke Einzel-Instanz│ glieder │ im System │
|
||||
└──────────────────────┴─────────────────────────┴───────────────────────┘
|
||||
```
|
||||
|
||||
1. Eigentümer öffnet „Teilen“ an der Liste, wählt Gruppe oder Benutzer und die Stufe **Bearbeiten** oder **Nur Lesen**.
|
||||
2. `POST /task-lists/{id}/shares` speichert `permission`; erneutes Hinzufügen desselben Ziels ändert die Stufe.
|
||||
3. `GET /task-lists` liefert je Liste `permission` (`owner`/`editor`/`viewer`). Das Frontend blendet für `viewer` alle Eingabemöglichkeiten aus und zeigt „Nur Lesen“.
|
||||
4. Der Server prüft unabhängig davon jeden Schreibzugriff (`403`, `storage.ErrTaskListReadOnly`).
|
||||
1. **`strict_private`:** Vollständige Abschottung. Benutzer wissen nicht voneinander. Keine Freigaben möglich.
|
||||
2. **`teams_only` (Standard):** Kollaboration ist strikt an Gruppen gebunden. Ein Benutzer kann Objekte nur mit Personen teilen, die mindestens in einer gemeinsamen Gruppe/Team mit ihm sind.
|
||||
3. **`open`:** Geeignet für Familien oder kleine Firmen. Jeder Benutzer kann für jeden anderen Benutzer Objekte freigeben.
|
||||
|
||||
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
|
||||
---
|
||||
|
||||
## Isolations-Modi (`system_settings.sharing_mode`)
|
||||
## 🏢 2. Gruppen & Teams (`teams` & `team_members`)
|
||||
|
||||
Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
||||
Gruppen strukturieren Benutzer (z.B. „Familie", „Entwicklung", „Marketing"):
|
||||
- **Gruppen-Admin:** Darf Mitglieder einladen, Rollen ändern oder die Gruppe löschen.
|
||||
- **Gruppen-Mitglied:** Hat Zugriff auf alle mit der Gruppe geteilten Ressourcen (Ordner, Aufgabenlisten, Einkaufslisten, Notizen).
|
||||
|
||||
| Modus | Zweck |
|
||||
|---|---|
|
||||
| `strict_private` | Keine Sichtbarkeit zwischen Benutzern |
|
||||
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
|
||||
| `open` | Jeder kann für jeden freigeben |
|
||||
Wird eine Ressource für eine Gruppe freigegeben, erhalten **alle aktuellen und zukünftigen Mitglieder** der Gruppe automatisch Zugriff, ohne dass Einzelberechtigungen gepflegt werden müssen.
|
||||
|
||||
**Tatsächlich** wird der Modus nur in `GetDiscoverableUsers` ausgewertet, und dort liefert `teams_only` bereits alle Benutzer. `GET /collaborators` ignoriert den Modus, und keine Freigabe-Funktion prüft ihn. Die Einstellung hat derzeit also kaum Wirkung ([FLAW-10](flaws.md)).
|
||||
---
|
||||
|
||||
## Entkoppelte Ablage geteilter Notizen und Ordner
|
||||
## 📁 3. Entkoppelte Ordner-Freigabe (Decoupled Folder Mounts)
|
||||
|
||||
- **Geteilte Ordner:** Der Ordner existiert einmal (`workspace_shared_folders`); jeder Empfänger hat seinen eigenen Einhängepfad (`workspace_folder_mounts.mount_path`). Verschiebt ein Empfänger den Ordner, ändert sich nur sein Pfad. Der Unterpfad innerhalb des Ordners (`documents.sub_folder_path`) gilt für alle.
|
||||
- **Einzelne Notizen:** Jeder Empfänger kann eine geteilte Notiz in einen eigenen Ordner legen (`doc_user_folders`); die Notiz des Eigentümers bleibt unberührt.
|
||||
### Das traditionelle Problem
|
||||
In herkömmlichen Cloud-Systemen (wie Google Drive oder Nextcloud) führt das Verschieben eines geteilten Ordners oft dazu, dass er auch bei allen anderen Teilnehmern den Platz wechselt. Verschiebt ein Kollege den Ordner in sein Unterverzeichnis „Archiv", verschwindet er beim Ersteller aus dem Sichtfeld.
|
||||
|
||||
## Sitzungen und Zugriff
|
||||
### Die MindSift-Lösung: Unabhängige Einhängepunkte (Mounts)
|
||||
MindSift entkoppelt die physische Ordner-Identität von der persönlichen Ordner-Hierarchie des Benutzers:
|
||||
|
||||
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
|
||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-06](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md), [FLAW-24](flaws.md).
|
||||
```
|
||||
Eigentümer (Alice):
|
||||
Ordner: "Kundenprojekte"
|
||||
└── Geteilter Ordner: "Projekt Phoenix" (wsf_123)
|
||||
├── spec.md
|
||||
└── roadmap.md
|
||||
|
||||
Empfänger (Bob):
|
||||
Ordner: "Meine Aufgaben"
|
||||
└── "Wichtiges"
|
||||
└── Einhängepunkt: "Projekt Phoenix" (wsf_123 eingehängt via mount_path)
|
||||
├── spec.md
|
||||
└── roadmap.md
|
||||
```
|
||||
|
||||
#### Wie es technisch funktioniert:
|
||||
1. Der geteilte Ordner existiert als Entität in `workspace_shared_folders`.
|
||||
2. Beim Eigentümer liegt der Ordner unter seinem Pfad.
|
||||
3. Jeder Empfänger erhält in `workspace_folder_mounts` seinen **eigenen lokalen `mount_path`**.
|
||||
4. **Unabhängiges Verschieben:** Schiebt Bob den Ordner an eine andere Stelle in seinem Dateibaum, wird **ausschließlich sein persönlicher `mount_path`** aktualisiert. Alice und andere Teammitglieder bemerken davon nichts.
|
||||
5. **Inhaltliche Synchronisation:** Verschiebt, erstellt oder bearbeitet jemand eine Notiz *innerhalb* des geteilten Ordners, wird der relative Unterpfad (`sub_folder_path`) synchronisiert, sodass alle dieselbe interne Dokumentstruktur sehen.
|
||||
|
||||
---
|
||||
|
||||
## 📄 4. Entkoppelte Notizen-Freigabe (`doc_user_folders`)
|
||||
|
||||
Dasselbe Prinzip gilt für einzeln geteilte Dokumente:
|
||||
- Erhält Benutzer Bob eine einzelne Notiz von Alice freigegeben, kann Bob diese Notiz in jeden beliebigen seiner eigenen Ordner einsortieren.
|
||||
- Seine persönliche Zuordnung wird in der Tabelle `doc_user_folders` (`user_id`, `doc_id`, `folder_path`) gespeichert.
|
||||
- Die Original-Notiz von Alice verbleibt an ihrem ursprünglichen Platz.
|
||||
|
||||
---
|
||||
|
||||
## 📋 5. Geteilte Aufgaben- & Einkaufslisten
|
||||
|
||||
- **Aufgabenlisten (`task_list_shares`):** Freigabe für Teams oder Einzelbenutzer mit Lese- oder Schreibrechten.
|
||||
- **Geteilte Listen (`list_shares`):**
|
||||
- Rolle `editor`: Einträge hinzufügen, ändern, abhaken und löschen.
|
||||
- Rolle `viewer`: Nur Lesezugriff (Echtzeit-Ansicht).
|
||||
- **Einheitlicher Freigabe-Dialog:** [`UnifiedShareModal.tsx`](../../frontend/src/components/UnifiedShareModal.tsx) bündelt alle Freigabeoptionen (Teams, Benutzer, Berechtigungsstufen) in einer konsistenten Benutzeroberfläche.
|
||||
+8
-8
@@ -6,7 +6,7 @@ MindSift implementiert ein radikal einfaches, modulares Theming-System. Anstatt
|
||||
|
||||
## 💡 1. Philosophie: 1 Datei = 1 komplettes Theme
|
||||
|
||||
Jedes Theme liegt im Verzeichnis [`frontend/src/themes/`](../frontend/src/themes/) und definiert alle visuellen Eigenschaften über standardisierte CSS-Variablen.
|
||||
Jedes Theme liegt im Verzeichnis [`frontend/src/themes/`](../../frontend/src/themes/) und definiert alle visuellen Eigenschaften über standardisierte CSS-Variablen.
|
||||
|
||||
### Trennung von Struktur und Design
|
||||
- **React-Komponenten** definieren ausschließlich semantische Klassen (wie `.panel-surface`, `.input-field`, `.btn-primary`, `text-main`, `bg-canvas`).
|
||||
@@ -78,17 +78,17 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
|
||||
|
||||
| Theme-ID | Datei | Stil & Charakter | Standard-Geometrie |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `dark` | [`theme-dark.css`](../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
|
||||
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
|
||||
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
|
||||
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
||||
| `dark` | [`theme-dark.css`](../../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||
| `light` | [`theme-light.css`](../../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
|
||||
| `high-contrast` | [`theme-high-contrast.css`](../../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
|
||||
| `soft` | [`theme-soft.css`](../../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
|
||||
| *Template* | [`theme-template.css`](../../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 4. Tailwind-Bridge & Semantische Klassen
|
||||
|
||||
In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js` sind die semantischen Utility-Klassen definiert, die sich automatisch an das gewählte Theme anpassen:
|
||||
In [`frontend/src/index.css`](../../frontend/src/index.css) und `tailwind.config.js` sind die semantischen Utility-Klassen definiert, die sich automatisch an das gewählte Theme anpassen:
|
||||
|
||||
- `.panel-surface`: Wendet `--bg-panel`, `--border-color` und `--radius-card` an.
|
||||
- `.input-field`: Wendet `--bg-input`, `--border-color`, `--radius-input` und `--text-main` an.
|
||||
@@ -105,5 +105,5 @@ Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
|
||||
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
|
||||
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
||||
3. Passe Farben und Rundungen nach Belieben an.
|
||||
4. Importiere die CSS-Datei in [`frontend/src/lib/theme.ts`](../frontend/src/lib/theme.ts) und trage das Theme im `AVAILABLE_THEMES`-Array ein.
|
||||
4. Importiere die CSS-Datei in [`frontend/src/lib/theme.ts`](../../frontend/src/lib/theme.ts) und trage das Theme im `AVAILABLE_THEMES`-Array ein.
|
||||
5. Das neue Theme steht sofort in den Kontoeinstellungen und im Profil-Dropdown zur Verfügung!
|
||||
@@ -5,194 +5,236 @@
|
||||
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](#)
|
||||
[](LICENSE)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
**MindSift** ist deine zentrale, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen mit Zoom-Fokus und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert und zu 100 % unter deiner eigenen Kontrolle.
|
||||
|
||||
<br />
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" />
|
||||
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
|
||||
</p>
|
||||
|
||||
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
|
||||
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 💡 Die Philosophie
|
||||
## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren*
|
||||
|
||||
Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn dir ein Gedanke kommt, willst du keinen Ordner suchen und keine Felder ausfüllen. MindSift trennt deshalb **Erfassen** und **Organisieren**:
|
||||
Klassische Produktivitäts- und Notiz-Apps scheitern im Alltag oft an derselben Hürde: **Sie verlangen Struktur im falschen Moment.**
|
||||
Wenn dir mitten im Gespräch, unterwegs auf dem Smartphone oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner suchen, keine Datenbankfelder ausfüllen und keine starren Formulare bedienen. Du willst den Gedanken in drei Sekunden festhalten – und sicher sein, dass er verlässlich da ist, wenn du Zeit hast.
|
||||
|
||||
1. **Abladen:** In die Eingabeleiste tippen, `Enter`, fertig. Wer will, weist den Gedanken gleich einer Aufgabe, Liste oder Notiz zu.
|
||||
2. **Sichten (Triage):** Später gehst du den Stapel Karte für Karte durch: verwerfen, zur Aufgabe machen, in eine Notiz oder Liste verwandeln.
|
||||
3. **Vertiefen:** Aus losen Ideen werden Aufgaben mit Unteraufgaben, vernetzte Outliner-Notizen oder geteilte Listen für Familie und Team.
|
||||
**MindSift trennt das Erfassen radikal vom Organisieren:**
|
||||
|
||||
1. **Sofort abladen (Quick Dump):** In die globale Eingabeleiste tippen, `Enter` drücken, Kopf wieder frei. Du kannst Gedanken aber auch direkt beim Eintippen gezielt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
|
||||
2. **Spielerisch sichten (Triage):** In ruhigen Momenten sichtest du deinen Stapel Karte für Karte – am Desktop per Tastaturkürzel oder auf dem Smartphone per Wischgeste. Mit einer Taste nach links verwerfen, auf „Später“ vertagen oder direkt in eine Aufgabe, Notiz oder Liste umwandeln.
|
||||
3. **Strukturiert vertiefen:** Aus losen Ideen werden vernetzte Outliner-Dokumente, hierarchische Unteraufgaben im Kanban-Board oder geteilte Listen für Familie, WG und Team.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Funktionen
|
||||
## ✨ Was MindSift auszeichnet
|
||||
|
||||
### ⚡ Schnellerfassung & Triage
|
||||
### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip)
|
||||
|
||||
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
|
||||
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
||||
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
||||
* Papierkorb mit Wiederherstellen.
|
||||
* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent im Header – jederzeit blitzschnell per Mausklick oder Tastendruck (`C` oder `/`) fokussierbar.
|
||||
* **Tastatur-Triage am Desktop:**
|
||||
* `H` oder `←`: Wegwerfen / Erledigt
|
||||
* `L` oder `→`: Sofort zur Aufgabe machen
|
||||
* `T`: Termin-Aufgabe mit Fälligkeitsdatum
|
||||
* `D`: In Workspace-Notiz umwandeln
|
||||
* `Leertaste`: Später (ans Ende des Stapels schieben)
|
||||
* **Mobile-First & Touch-optimiert:** Unterwegs kannst du Gedanken per Wischgeste (Swipe nach links zum Verwerfen, Swipe nach rechts zum Behalten) sortieren.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
|
||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" style="border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.25);" />
|
||||
</p>
|
||||
|
||||
### ✅ Aufgaben & Kanban
|
||||
---
|
||||
|
||||
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
||||
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
|
||||
### 📝 Workspace: Der hierarchische Outliner mit Zoom-Fokus
|
||||
|
||||
Der MindSift-Workspace vereint die Übersicht eines klassischen Dokumenten-Explorers mit der Leichtigkeit eines hierarchischen Outliners (inspiriert von Logseq und Roam).
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
|
||||
</p>
|
||||
|
||||
### 📝 Workspace: Outliner-Notizen
|
||||
* **Jeder Gedanke ein Bullet-Point:**
|
||||
* `Enter`: Neuer Bullet-Point auf derselben Ebene
|
||||
* `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken
|
||||
* `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks
|
||||
* **🔍 Fokus-Modus & Zoom-in (Hoisting):**
|
||||
* Klicke auf einen Bullet-Punkt (`•`), um **isoliert in diesen Ast hineinzuzoomen**.
|
||||
* Der gewählte Punkt wird zur prominenten Überschrift, sodass du dich ohne jede Ablenkung auf genau dieses Thema konzentrieren kannst.
|
||||
* Eine klickbare Breadcrumb-Leiste oben zeigt deinen aktuellen Pfad und führt dich mit einem Klick zurück in übergeordnete Ebenen oder ins Hauptdokument.
|
||||
* **Intuitives Drag & Drop für Bullet-Points:**
|
||||
* Verschiebe Absätze und ganze Gedankenbäume mühelos mit der Maus.
|
||||
* **Unterpunkte per Drop erstellen:** Ziehst du einen Bullet-Point direkt auf einen anderen Aufzählungspunkt, wird er automatisch als Unter-Bullet eingerückt. Eingeklappte Eltern-Punkte klappen beim Hineinlegen automatisch auf.
|
||||
* Untergeordnete Unterpunkte werden beim Ziehen automatisch als zusammenhängender Ast mitbewegt.
|
||||
* **Live-Kollaboration & Presence-Tracking:**
|
||||
* Gleichzeitiges, latenzfreies Bearbeiten von Dokumenten im Team ohne blockierende Sperren.
|
||||
* Farbige Avatare im Header und Live-Cursors am aktiven Block visualisieren genau, wer gerade wo tippt.
|
||||
* Strikter Echo-Schutz verhindert Cursor-Springen und Flackern beim Tippen.
|
||||
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/link` (Notizen & Web-Links verknüpfen), `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote` oder `/divider`.
|
||||
* **Vernetzung & Hashtags:** Verlinke Notizen untereinander mit Wiki-Links (`[[Notiztitel]]`) und tagge Gedanken mit `#Hashtags`.
|
||||
* **Explorer mit Ordnern & Favoriten:** Organisiere Dokumente hierarchisch in Ordnern, verschiebe Notizen per Drag & Drop und hefte deine wichtigsten Dokumente als Favoriten ganz oben an.
|
||||
* **100 % Pure-DB & Markdown-Export:** Alle Notizen und Blöcke werden transaktional und sicher in SQLite gespeichert. Beim Speichern wird automatisch ein sauberer Markdown-Cache generiert, und Dokumente können jederzeit mit einem Klick als `.md`-Datei exportiert werden.
|
||||
|
||||
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
||||
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
||||
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
|
||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
||||
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt.
|
||||
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen **zum Lesen** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
||||
---
|
||||
|
||||
### ✅ Aufgabenlisten & Kanban-Board
|
||||
|
||||
Behalte offene Aufgaben, Projekte und Termine im Blick – wahlweise als strukturierte Liste oder als visuelles Kanban-Board.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
|
||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
|
||||
</p>
|
||||
|
||||
### 🛒 Geteilte Listen
|
||||
* **Flexible Ansichten (Liste & Kanban):** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem interaktiven Kanban-Board (*Zu erledigen*, *In Arbeit*, *Wartend*, *Erledigt*).
|
||||
* **Unteraufgaben (Subtasks):**
|
||||
* Gliedere komplexe Aufgaben in beliebig viele Teilaufgaben.
|
||||
* Ein visueller Fortschrittsbalken und eine Prozentanzeige zeigen dir auf einen Blick, wie weit die Aufgabe gediehen ist.
|
||||
* **Freies Drag & Drop:**
|
||||
* Sortiere Aufgaben vertikal in deiner bevorzugten Reihenfolge.
|
||||
* Im Kanban-Board verschiebst du Karten per Drag & Drop direkt zwischen Status-Spalten oder ordnest sie innerhalb einer Spalte um.
|
||||
* Kein mühsames Suchen nach Greifern: Klicke und ziehe eine Aufgabe einfach direkt mit der Maus – ein kurzer Klick ohne Ziehen öffnet das Detail-Modal zum Bearbeiten.
|
||||
* **Prioritäten & Termine:** Hebe dringende oder wichtige Aufgaben mit Farbcodes und Icons hervor (🔥 Dringend, ⚠️ Wichtig). Setze Fälligkeitsdaten und filtere nach Hashtags.
|
||||
* **Geteilte Aufgabenlisten:** Erstelle separate Listen für Beruf, Homelab oder Familie und teile sie gezielt mit Gruppen, Teams oder einzelnen Nutzern.
|
||||
|
||||
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
|
||||
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
||||
---
|
||||
|
||||
### 🛒 Geteilte Listen (Einkauf & Checklisten)
|
||||
|
||||
Ob Wochenmarkt, Baumarkt oder Urlaubs-Packliste – erstelle geteilte Listen, die für gemeinsames Arbeiten in Echtzeit optimiert sind.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
|
||||
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
|
||||
</p>
|
||||
|
||||
### 🏢 Benutzer, Gruppen, Administration
|
||||
* **Automatische Hashtag-Kategorien:**
|
||||
* Tagge Artikel einfach mit einem Hashtag (z. B. `Hafermilch #Kühlregal` oder `Bananen #Obst`).
|
||||
* MindSift sortiert die Kategorien automatisch alphabetisch und zeigt sie in übersichtlichen Sektionen mit Kacheln an.
|
||||
* **Hashtag-Autovervollständigung:** Bereits beim Tippen eines `#` schlägt dir die Eingabe existierende Hashtags vor (case-insensitive) – mit Tastaturnavigation und Sofort-Übernahme per `Enter`.
|
||||
* **Mengenangaben & Schnell-Eingabe:** Erfasse Mengen (`2x`, `500g`) direkt im Eingabefeld.
|
||||
* **Drag & Drop zwischen Kategorien:** Ziehe Einträge mit der Maus nach oben oder unten oder schiebe sie direkt in einen anderen Hashtag-Bereich – der Hashtag im Text wird automatisch aktualisiert.
|
||||
* **Gemeinsam teilen mit Gruppen & Teams:**
|
||||
* Über das einheitliche Teilen-Modal kannst du jede Liste mit deinen Teams, Arbeitsgruppen oder Einzelpersonen freigeben.
|
||||
* Avatare und Freigabe-Badges im Header zeigen dir transparent, wer Zugriff hat.
|
||||
* **Echtzeit-WebSocket-Synchronisation:** Wird ein Artikel im Supermarkt abgehakt, hakt er sich bei allen Beteiligten im selben Augenblick live ab.
|
||||
* **Eckiges Abhaken & Aufräumen:** Erledigte Punkte wandern in einen separaten Bereich und können mit einem Klick aufgeräumt werden.
|
||||
|
||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
||||
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
|
||||
---
|
||||
|
||||
### 🔌 Integrationen
|
||||
### 📋 Globale To-Dos: Das automatische Notizen-Rollup
|
||||
|
||||
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
|
||||
* Persönliche API-Keys, Datenexport als JSON.
|
||||
* **Automatische Erfassung:** MindSift durchsucht alle Workspace-Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard.
|
||||
* **Ordner-Filter:** Isoliere Aufgaben bestimmter Projekte mit einem Klick.
|
||||
* **2-Wege-Echtzeit-Sync:** Hakst du ein To-Do im Rollup ab, wird die physische Markdown-Datei auf der Festplatte augenblicklich als `- [x]` aktualisiert.
|
||||
|
||||
---
|
||||
|
||||
### 🏢 Benutzer, Gruppen & Administration
|
||||
|
||||
* **Geführte Ersteinrichtung:** Beim ersten Start führt dich ein Assistent durch die Einrichtung deines Administrator-Kontos.
|
||||
* **Eigenständiger Gruppen-Bereich:** Verwalte eigene Gruppen und Teams direkt über das Benutzer-Menü oben rechts.
|
||||
* **Gruppen-Administration:** Wer eine Gruppe anlegt oder zum Gruppen-Admin ernannt wird, hat die volle Kontrolle über die Mitglieder dieser Gruppe.
|
||||
* **Server-Sicherheit:** Schalte die öffentliche Registrierung ab und lade neue Mitglieder kontrolliert per Einladungscode ein.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Schnellstart
|
||||
|
||||
MindSift läuft als einzelner Container mit Webserver, API, Datenbank und Frontend.
|
||||
MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, SQLite-Datenbank und Frontend direkt mit.
|
||||
|
||||
### Option 1: Installationsskript
|
||||
### Option 1: Der 1-Zeiler (Empfohlen)
|
||||
|
||||
Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, generiert ein sicheres JWT-Geheimnis und startet den Container:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash
|
||||
```
|
||||
|
||||
Das Skript legt ein Verzeichnis an, lädt `docker-compose.yml` und `.env.example`, erzeugt ein `JWT_SECRET` und startet den Container.
|
||||
---
|
||||
|
||||
### Option 2: Docker Compose
|
||||
### Option 2: Schneller Start mit zwei `curl`-Befehlen
|
||||
|
||||
Falls du die Dateien direkt ohne Skript herunterladen möchtest:
|
||||
|
||||
```bash
|
||||
mkdir -p mindsift && cd mindsift
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/docker-compose.yml -o docker-compose.yml
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env
|
||||
# In .env: JWT_SECRET durch einen langen Zufallswert ersetzen!
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Minimal:
|
||||
---
|
||||
|
||||
### Option 3: Manuelle `docker-compose.yml`
|
||||
|
||||
Erstelle eine Datei namens `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
container_name: mindsift
|
||||
restart: unless-stopped
|
||||
ports: ["8080:8080"]
|
||||
volumes: ["./data:/data"]
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- JWT_SECRET=hier-ein-langer-zufaelliger-wert
|
||||
- BASE_URL=https://mindsift.example.com
|
||||
- PORT=8080
|
||||
- DATA_DIR=/data
|
||||
- JWT_SECRET=hier-ein-langes-sicheres-zufalls-geheimnis-eintragen
|
||||
- ALLOW_REGISTRATION=false
|
||||
# Optional: Mailserver für E-Mail-Alarme (auch im Admin-UI einstellbar)
|
||||
- SMTP_HOST=
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=
|
||||
- SMTP_PASS=
|
||||
- SMTP_FROM=
|
||||
- SMTP_TLS=true
|
||||
```
|
||||
|
||||
### Ersteinrichtung
|
||||
|
||||
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
|
||||
|
||||
> ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Betrieb & Konfiguration
|
||||
|
||||
| Variable | Standard | Zweck |
|
||||
|---|---|---|
|
||||
| `PORT` | `8080` | HTTP-Port |
|
||||
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
||||
| `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel |
|
||||
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
|
||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin`, `user/user` an. **Nie auf öffentlichen Servern!** |
|
||||
|
||||
* **Reverse-Proxy:** WebSocket-Upgrade und ungepufferte SSE-Streams müssen durchgereicht werden. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
||||
* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
|
||||
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Entwicklung
|
||||
|
||||
Voraussetzungen: Go 1.23, Node 22.
|
||||
Starte den Container:
|
||||
|
||||
```bash
|
||||
# Backend: Vet und Tests
|
||||
go vet ./... && go test ./...
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # Vite-Entwicklungsserver
|
||||
npx tsc --noEmit # Typprüfung
|
||||
npm run build # Produktions-Build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Vet und Tests beim Image-Build aus.
|
||||
|
||||
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Dokumentation
|
||||
### 🌐 Ersteinrichtung aufrufen
|
||||
|
||||
Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module, Freigaben und Berechtigungen, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md).
|
||||
Öffne deinen Browser unter **`http://localhost:8080`** (bzw. die IP deines Servers).
|
||||
MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtungs-Assistenten zum Erstellen des Administrator-Kontos.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Für wen ist MindSift gemacht?
|
||||
|
||||
* **Für Macher & Wissensarbeiter**, die tagsüber dutzende Gedanken, Links und Aufgaben festhalten müssen, ohne aus dem Arbeitsfluss gerissen zu werden.
|
||||
* **Für Outliner-Fans**, die die Flexibilität von Logseq schätzen, aber ihre Notizen in einer klaren Ordnerstruktur auf dem eigenen Server haben wollen.
|
||||
* **Für Familien & WGs**, die gemeinsame Einkaufs- und Packlisten ohne Werbe-Apps und fremde Clouds synchronisieren möchten.
|
||||
* **Für Homelab- & Self-Hosting-Begeisterte**, die volle Datenhoheit, saubere Markdown-Dateien und automatisierte Webhooks auf eigener Hardware schätzen.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Deine Daten gehören dir
|
||||
|
||||
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
||||
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
|
||||
* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking.
|
||||
* **Kein Vendor-Lock-in:** Alle Notizen liegen als relationale Daten in SQLite und werden automatisch als Standard-Markdown kompiliert. Dokumente lassen sich jederzeit mit einem Klick als `.md`-Datei exportieren oder als SQLite-Dump sichern.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
Im Repository liegt derzeit keine `LICENSE`-Datei; die Lizenz ist damit nicht festgelegt.
|
||||
Veröffentlicht unter der **MIT-Lizenz** – siehe [LICENSE](LICENSE).
|
||||
|
||||
<div align="center">
|
||||
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
</div>
|
||||
<sub>Gebaut für maximale Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
</div>
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Webhook,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
@@ -40,7 +39,6 @@ interface TaskDetailModalProps {
|
||||
onConvertToDoc: (task: Thought) => void;
|
||||
onOpenDocument?: (docId: string, blockId?: string) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
|
||||
}
|
||||
|
||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
@@ -62,7 +60,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onConvertToDoc,
|
||||
onOpenDocument,
|
||||
onDeleteTask,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
@@ -179,7 +176,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
disabled={readOnly}
|
||||
onClick={() => {
|
||||
triggerHaptic(15);
|
||||
const nextState = !task.isCompleted;
|
||||
@@ -188,7 +184,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
||||
onUpdateStatus(task, nextKanban);
|
||||
}}
|
||||
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
|
||||
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
||||
task.isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
@@ -198,14 +194,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-main truncate">
|
||||
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||
{task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||
</span>
|
||||
{readOnly && (
|
||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
||||
<Lock className="w-2.5 h-2.5 text-faint" />
|
||||
<span>Nur Lesen</span>
|
||||
</span>
|
||||
)}
|
||||
{task.source?.toLowerCase() === 'webhook' && (
|
||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
||||
@@ -230,7 +220,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
<span>In Notiz öffnen</span>
|
||||
</button>
|
||||
) : !readOnly && (
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -244,7 +234,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -259,7 +248,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
<span className="sm:hidden font-medium">Löschen</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -271,8 +259,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
||||
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
|
||||
{/* Scrollable Body */}
|
||||
<div className="p-4 overflow-y-auto space-y-4 flex-1">
|
||||
{/* Task Title */}
|
||||
<div>
|
||||
{isEditingTitle ? (
|
||||
@@ -315,11 +303,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => !readOnly && setIsEditingTitle(true)}
|
||||
className={`group p-2 rounded-card transition-colors border border-transparent ${
|
||||
readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border'
|
||||
}`}
|
||||
title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'}
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border"
|
||||
title="Klicken zum Bearbeiten des Titels"
|
||||
>
|
||||
<h2
|
||||
className={`text-base font-semibold break-words leading-snug ${
|
||||
@@ -328,11 +314,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
>
|
||||
{task.content}
|
||||
</h2>
|
||||
{!readOnly && (
|
||||
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Klicken zum Umbenennen
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Klicken zum Umbenennen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -531,18 +515,15 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
|
||||
>
|
||||
<span>#{tag}</span>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted hover:text-status-err"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted hover:text-status-err"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
@@ -552,7 +533,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
className="h-6 w-24 px-1.5 text-xs input-field"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -607,7 +587,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
{st.title}
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteSubtask(task, st.id)}
|
||||
@@ -615,14 +594,12 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add subtask form */}
|
||||
{!readOnly && (
|
||||
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
@@ -640,7 +617,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes Section */}
|
||||
@@ -650,7 +626,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Detailnotizen:</span>
|
||||
</label>
|
||||
{!readOnly && !isEditingNotes && task.notes && (
|
||||
{!isEditingNotes && task.notes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -697,19 +673,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
) : task.notes ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
|
||||
readOnly ? '' : 'cursor-pointer hover:border-strong'
|
||||
}`}
|
||||
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed cursor-pointer hover:border-strong transition-colors"
|
||||
title="Klicken zum Bearbeiten"
|
||||
>
|
||||
<MarkdownViewer content={task.notes} />
|
||||
</div>
|
||||
) : readOnly ? (
|
||||
<p className="text-xs text-faint">Keine Detailnotizen.</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -724,7 +695,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||
|
||||
@@ -229,9 +229,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
|
||||
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
|
||||
const isSelectedListReadOnly = currentSelectedList?.permission === 'viewer';
|
||||
const isTaskReadOnly = (task: Thought) =>
|
||||
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
|
||||
|
||||
// Active Kanban Columns (from selected list, or global custom, or default)
|
||||
const activeColumns: KanbanColumn[] = useMemo(() => {
|
||||
@@ -605,10 +602,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const handleTaskDragStart = (e: React.DragEvent, task: Thought) => {
|
||||
e.stopPropagation();
|
||||
if (isTaskReadOnly(task)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.dataTransfer.setData('application/mindsift-task-id', task.id);
|
||||
e.dataTransfer.setData('text/plain', task.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
@@ -872,7 +865,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
setPrimaryAction(() => {
|
||||
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
|
||||
setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId);
|
||||
setShowMobileCreateModal(true);
|
||||
});
|
||||
|
||||
@@ -883,7 +876,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
icon: <Plus className="w-4 h-4" />,
|
||||
variant: 'accent',
|
||||
onClick: () => {
|
||||
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
|
||||
setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId);
|
||||
setShowMobileCreateModal(true);
|
||||
},
|
||||
},
|
||||
@@ -972,7 +965,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
},
|
||||
];
|
||||
|
||||
if (viewMode === 'kanban' && !isSelectedListReadOnly) {
|
||||
if (viewMode === 'kanban') {
|
||||
viewItems.push({
|
||||
id: 'manage-columns',
|
||||
label: 'Kanban-Spalten verwalten',
|
||||
@@ -1037,7 +1030,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
kanbanTasks.length,
|
||||
safeTaskLists,
|
||||
currentSelectedList,
|
||||
isSelectedListReadOnly,
|
||||
setPrimaryAction,
|
||||
setGroups,
|
||||
]);
|
||||
@@ -1079,14 +1071,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
// Clean Task Row in List View
|
||||
const renderTaskRow = (task: Thought) => {
|
||||
const isCompleted = task.isCompleted;
|
||||
const readOnly = isTaskReadOnly(task);
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable={!readOnly}
|
||||
draggable
|
||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTaskId(null);
|
||||
@@ -1100,9 +1091,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDetailTaskId(task.id);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer select-none ${
|
||||
readOnly ? '' : 'cursor-grab active:cursor-grabbing'
|
||||
} ${
|
||||
className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer cursor-grab active:cursor-grabbing select-none ${
|
||||
isCompleted ? 'opacity-65' : ''
|
||||
} ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
|
||||
>
|
||||
@@ -1119,18 +1108,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
disabled={readOnly}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleDone(task);
|
||||
}}
|
||||
className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
|
||||
className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
||||
isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
title={readOnly ? 'Nur Lesen' : isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
|
||||
title={isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
|
||||
</button>
|
||||
@@ -1181,7 +1169,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
|
||||
<select
|
||||
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => handleSetKanbanStatus(task, e.target.value)}
|
||||
className="hidden sm:inline-block h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer"
|
||||
title="Status / Zustand ändern"
|
||||
@@ -1197,9 +1184,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{task.priority === 'urgent' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => handleCyclePriority(task)}
|
||||
className="badge-accent text-[10px] cursor-pointer hover:opacity-85 disabled:cursor-default"
|
||||
className="badge-accent text-[10px] cursor-pointer hover:opacity-85"
|
||||
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
|
||||
>
|
||||
🔥 Dringend
|
||||
@@ -1208,9 +1194,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{task.priority === 'high' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => handleCyclePriority(task)}
|
||||
className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
|
||||
className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85"
|
||||
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
|
||||
>
|
||||
⚠️ Wichtig
|
||||
@@ -1281,12 +1266,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className="text-faint hover:text-accent p-1.5 transition-colors"
|
||||
title={readOnly ? 'Aufgabe ansehen' : 'Aufgabe bearbeiten'}
|
||||
title="Aufgabe bearbeiten"
|
||||
>
|
||||
{readOnly ? <Lock className="w-3.5 h-3.5" /> : <Edit3 className="w-3.5 h-3.5" />}
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
@@ -1305,8 +1288,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1498,17 +1479,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelectedListReadOnly && (
|
||||
<div className="flex items-center gap-2 p-2.5 rounded-btn bg-panel-elevated border border-border text-xs text-muted">
|
||||
<Lock className="w-3.5 h-3.5 text-faint shrink-0" />
|
||||
<span>
|
||||
Diese Liste wurde von {currentSelectedList?.ownerName || 'einem anderen Benutzer'} nur zum Lesen freigegeben.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */}
|
||||
{!isSelectedListReadOnly && (
|
||||
<form
|
||||
onSubmit={handleQuickAddTask}
|
||||
className="hidden sm:flex items-center gap-1.5 panel-surface p-2 sm:p-1.5"
|
||||
@@ -1543,7 +1514,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Mobile Active Filter Badge Banner (minimal and clean) */}
|
||||
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
|
||||
@@ -1628,7 +1598,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{/* Group By & Sort By Dropdowns & Manage Columns button */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
|
||||
{viewMode === 'kanban' ? (
|
||||
!isSelectedListReadOnly && (
|
||||
<button
|
||||
onClick={() => setShowManageColumnsModal(true)}
|
||||
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
|
||||
@@ -1637,7 +1606,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<Columns className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Spalten anpassen</span>
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{/* Group By */}
|
||||
@@ -2034,17 +2002,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
|
||||
targetEmail: sh.email,
|
||||
avatarUrl: sh.avatarUrl,
|
||||
permission: sh.permission === 'viewer' ? 'viewer' as const : 'editor' as const,
|
||||
role: sh.permission === 'viewer' ? 'viewer' : 'editor',
|
||||
permission: 'editor' as const,
|
||||
role: 'editor',
|
||||
createdAt: sh.createdAt,
|
||||
}))}
|
||||
teams={safeTeams}
|
||||
collaborators={safeCollaborators}
|
||||
onAddShare={async (targetType, targetId, permission) => {
|
||||
allowRoleSelection={false}
|
||||
fixedPermission="editor"
|
||||
onAddShare={async (targetType, targetId) => {
|
||||
await api.addTaskListShare(sharingTaskList.id, {
|
||||
targetType,
|
||||
targetId,
|
||||
permission,
|
||||
});
|
||||
const [freshLists, freshShares] = await Promise.all([
|
||||
api.getTaskLists(),
|
||||
@@ -2105,7 +2074,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onDeleteSubtask={handleDeleteSubtask}
|
||||
onConvertToDoc={handleConvertToDoc}
|
||||
onDeleteTask={handleDeletePermanent}
|
||||
readOnly={detailTask ? isTaskReadOnly(detailTask) : false}
|
||||
/>
|
||||
|
||||
{/* VIEW MODES: LIST OR KANBAN */}
|
||||
@@ -2162,12 +2130,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{colTasks.map((task) => {
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||
const readOnly = isTaskReadOnly(task);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable={!readOnly}
|
||||
draggable
|
||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTaskId(null);
|
||||
@@ -2181,9 +2148,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDetailTaskId(task.id);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${
|
||||
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
||||
} ${
|
||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-grab active:cursor-grabbing shadow-sm group relative ${
|
||||
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -2202,7 +2167,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
>
|
||||
{task.content}
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => handleConvertToDoc(task)}
|
||||
@@ -2219,7 +2183,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtasks progress */}
|
||||
@@ -2254,9 +2217,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{task.priority === 'urgent' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => handleCyclePriority(task)}
|
||||
className="badge-accent text-[9px] cursor-pointer hover:opacity-85 disabled:cursor-default"
|
||||
className="badge-accent text-[9px] cursor-pointer hover:opacity-85"
|
||||
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
|
||||
>
|
||||
🔥 Dringend
|
||||
@@ -2265,9 +2227,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{task.priority === 'high' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => handleCyclePriority(task)}
|
||||
className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
|
||||
className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85"
|
||||
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
|
||||
>
|
||||
⚠️ Wichtig
|
||||
@@ -2305,7 +2266,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Column Navigation controls: Move Left / Move Right */}
|
||||
{!readOnly && (
|
||||
<div
|
||||
className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -2332,7 +2292,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -2345,7 +2304,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Quick Add Task to Column */}
|
||||
{!isSelectedListReadOnly && (
|
||||
<div className="p-2 border-t border-border bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2356,7 +2314,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<span>Aufgabe hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -430,7 +430,7 @@ export const api = {
|
||||
|
||||
async addTaskListShare(
|
||||
id: string,
|
||||
payload: { targetType: 'team' | 'user'; targetId: string; permission?: 'editor' | 'viewer' }
|
||||
payload: { targetType: 'team' | 'user'; targetId: string }
|
||||
): Promise<TaskListShare> {
|
||||
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -83,7 +83,6 @@ export interface TaskListShare {
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
permission?: 'editor' | 'viewer';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -105,7 +104,6 @@ export interface TaskList {
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
permission?: 'owner' | 'editor' | 'viewer';
|
||||
members?: TaskListMember[];
|
||||
shares?: TaskListShare[];
|
||||
columns?: KanbanColumn[];
|
||||
|
||||
+6
-36
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -244,15 +243,6 @@ func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
// writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status.
|
||||
func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) {
|
||||
if errors.Is(err, storage.ErrTaskListReadOnly) {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, fallbackStatus, err.Error())
|
||||
}
|
||||
|
||||
func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only log API requests (/api/...)
|
||||
@@ -683,7 +673,7 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -774,7 +764,7 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -815,17 +805,6 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
|
||||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||
return
|
||||
}
|
||||
for _, it := range req.Items {
|
||||
if th, err := s.store.GetThoughtByID(userID, it.ID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
|
||||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -861,7 +840,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
|
||||
@@ -879,13 +858,9 @@ func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request
|
||||
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
if th, err := s.store.GetThoughtByID(userID, thoughtID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
|
||||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||
return
|
||||
}
|
||||
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||||
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
||||
writeStoreError(w, http.StatusInternalServerError, err)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID})
|
||||
@@ -2182,7 +2157,7 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
|
||||
@@ -2230,17 +2205,12 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
|
||||
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)
|
||||
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
||||
@@ -184,7 +184,6 @@ type TaskListShare struct {
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Permission string `json:"permission"` // editor | viewer
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -212,7 +211,6 @@ type TaskList struct {
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission,omitempty"` // owner | editor | viewer
|
||||
Members []TaskListMember `json:"members,omitempty"`
|
||||
Shares []TaskListShare `json:"shares,omitempty"`
|
||||
Columns []KanbanColumn `json:"columns,omitempty"`
|
||||
|
||||
+10
-100
@@ -356,7 +356,6 @@ func (s *Store) migrate() error {
|
||||
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);",
|
||||
"ALTER TABLE task_list_shares ADD COLUMN permission TEXT NOT NULL DEFAULT 'editor';",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS doc_team_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, team_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, team_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);",
|
||||
@@ -911,9 +910,6 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
|
||||
if kanbanStatus == "" {
|
||||
kanbanStatus = "todo"
|
||||
}
|
||||
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags := domain.ExtractTags(content)
|
||||
if strings.EqualFold(source, "voice") {
|
||||
@@ -1149,13 +1145,8 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
||||
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 {
|
||||
@@ -1263,14 +1254,6 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
@@ -1339,9 +1322,6 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||||
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
|
||||
}
|
||||
@@ -4598,73 +4578,12 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
|
||||
return nil, err
|
||||
}
|
||||
if teamID != "" {
|
||||
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
|
||||
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, 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)")
|
||||
|
||||
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
|
||||
func (s *Store) GetTaskListPermission(userID, listID string) string {
|
||||
var ownerID, teamID string
|
||||
if err := s.db.QueryRow(`SELECT user_id, COALESCE(team_id, '') FROM task_lists WHERE id = ?`, listID).Scan(&ownerID, &teamID); 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()
|
||||
}
|
||||
if perm == "" && teamID != "" {
|
||||
// Legacy team assignment without a share row grants edit access
|
||||
var isMember, hasShareRow int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&isMember)
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM task_list_shares WHERE task_list_id = ? AND target_type = 'team' AND target_id = ?`, listID, teamID).Scan(&hasShareRow)
|
||||
if isMember > 0 && hasShareRow == 0 {
|
||||
perm = "editor"
|
||||
}
|
||||
}
|
||||
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) {
|
||||
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy string) (*domain.TaskListShare, error) {
|
||||
if targetType != "team" && targetType != "user" {
|
||||
return nil, errors.New("ungültiger Freigabetyp")
|
||||
}
|
||||
@@ -4672,23 +4591,19 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, c
|
||||
if targetID == "" {
|
||||
return nil, errors.New("Ziel-ID erforderlich")
|
||||
}
|
||||
if permission != "viewer" {
|
||||
permission = "editor"
|
||||
}
|
||||
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,
|
||||
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at`,
|
||||
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -4721,7 +4636,7 @@ func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error
|
||||
|
||||
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
|
||||
`SELECT id, task_list_id, target_type, target_id, created_at
|
||||
FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`,
|
||||
taskListID,
|
||||
)
|
||||
@@ -4733,7 +4648,7 @@ func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, er
|
||||
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 {
|
||||
if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.CreatedAt); err == nil {
|
||||
shares = append(shares, sh)
|
||||
}
|
||||
}
|
||||
@@ -4868,7 +4783,6 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
|
||||
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
|
||||
@@ -4911,7 +4825,6 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
|
||||
}
|
||||
shares, _ := s.GetTaskListShares(tl.ID)
|
||||
tl.Shares = shares
|
||||
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
|
||||
tl.Members = s.getMembersForTaskList(&tl)
|
||||
return &tl, nil
|
||||
}
|
||||
@@ -4921,9 +4834,6 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tl.Permission == "viewer" {
|
||||
return nil, ErrTaskListReadOnly
|
||||
}
|
||||
if title != nil {
|
||||
tl.Title = strings.TrimSpace(*title)
|
||||
}
|
||||
@@ -4945,8 +4855,8 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
|
||||
if oldTeamID != "" && oldTeamID != *teamID {
|
||||
_ = s.RemoveTaskListShare(listID, oldTeamID)
|
||||
}
|
||||
if *teamID != "" && *teamID != oldTeamID {
|
||||
_, _ = s.AddTaskListShare(listID, "team", *teamID, "editor", userID)
|
||||
if *teamID != "" {
|
||||
_, _ = s.AddTaskListShare(listID, "team", *teamID, userID)
|
||||
}
|
||||
}
|
||||
tl.UpdatedAt = domain.NowISO()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -119,7 +118,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test TaskList Sharing with Team
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, "editor", user.ID)
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID)
|
||||
if err != nil || shareTeam.ID == "" {
|
||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||
}
|
||||
@@ -148,45 +147,6 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser collab failed: %v", err)
|
||||
}
|
||||
|
||||
// Test read-only TaskList share
|
||||
ownerTask, err := store.CreateThoughtWithDetails(user.ID, "Rasen mähen", "normal", "manual", "", taskList.ID, "todo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThoughtWithDetails in own list failed: %v", err)
|
||||
}
|
||||
if _, err := store.UpdateThoughtTriage(user.ID, ownerTask.ID, "keep", "task", "", "", taskList.ID); err != nil {
|
||||
t.Fatalf("UpdateThoughtTriage in own list failed: %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "viewer", user.ID); err != nil {
|
||||
t.Fatalf("AddTaskListShare viewer failed: %v", err)
|
||||
}
|
||||
if perm := store.GetTaskListPermission(user2.ID, taskList.ID); perm != "viewer" {
|
||||
t.Fatalf("expected viewer permission, got %q", perm)
|
||||
}
|
||||
viewerTasks, err := store.GetTasks(user2.ID, taskList.ID)
|
||||
if err != nil || len(viewerTasks) == 0 {
|
||||
t.Fatalf("viewer should see tasks of shared list: %v", err)
|
||||
}
|
||||
done := true
|
||||
if _, err := store.UpdateThoughtDetails(user2.ID, ownerTask.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update tasks, got %v", err)
|
||||
}
|
||||
if _, err := store.CreateThoughtWithDetails(user2.ID, "Hack", "normal", "manual", "", taskList.ID, "todo", ""); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not create tasks, got %v", err)
|
||||
}
|
||||
if err := store.DeleteThought(user2.ID, ownerTask.ID); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not delete tasks, got %v", err)
|
||||
}
|
||||
newTitle := "Umbenannt"
|
||||
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update task list, got %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
|
||||
t.Fatalf("upgrading share to editor failed: %v", err)
|
||||
}
|
||||
if !store.CanEditTaskList(user2.ID, taskList.ID) {
|
||||
t.Fatalf("editor share should allow editing")
|
||||
}
|
||||
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
|
||||
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
||||
}, []string{"test"}, false)
|
||||
|
||||
Reference in new issue
Block a user