diff --git a/.env.example b/.env.example index 09e1e57..25e469e 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,11 @@ TZ=Europe/Berlin # Alt: true entspricht REGISTRATION_MODE=open; false lässt die Wahl dem Admin. ALLOW_REGISTRATION=false +# Aufbewahrung: Darf jede Person „nie“ wählen, also Erledigtes und Gelöschtes dauerhaft behalten? +# false = nein (es gilt die Voreinstellung des Bereichs, z. B. 30 Tage), true = ja. Ohne Angabe +# entscheidet Administration → Server & Isolation (Standard: erlaubt). +# RETENTION_ALLOW_NEVER=false + # ─── Sicherheit ──────────────────────────────────────────────────────────────────────────────────── # Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die diff --git a/CHANGELOG.md b/CHANGELOG.md index 270b01e..58e2a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.43.0] - 2026-11-01 + +### Added +- Papierkorb für Aufgaben: Gelöschte Aufgaben, „Erledigte leeren“ und das automatische Aufräumen erledigter Aufgaben landen im Reiter „Papierkorb“ der Aufgaben, statt sofort weg zu sein. Von dort kommen sie unverändert zurück (mit Beschreibung, Unteraufgaben, Zuweisungen) oder werden endgültig gelöscht. Wer eine Liste sieht, sieht auch ihren Papierkorb; Zurückholen und Löschen braucht Bearbeitungsrecht. Auch `delete_task` per MCP legt in den Papierkorb. +- Eigener Papierkorb für die Triage: verworfene Gedanken öffnen sich über den Papierkorb-Knopf oben in der Triage (am Handy im Menü). +- Kontoeinstellungen → Aufbewahrung: für erledigte Aufgaben (Standard 30 Tage, danach in den Papierkorb), den Papierkorb der Aufgaben (30), der Triage (14), der Notizen (30) und abgehakte Listeneinträge (7) jeweils 7, 14, 30 oder 90 Tage, ein oder zwei Jahre oder nie. In geteilten Aufgabenlisten, Listen und Notizen gilt die Einstellung der Person, der sie gehören. Was beim Update schon erledigt oder gelöscht war, zählt ab dem Update. +- Administration: „Dauerhaftes Aufbewahren erlauben“ (bzw. `RETENTION_ALLOW_NEVER`). Ausgeschaltet gibt es „nie“ nicht mehr; dort gilt dann die Voreinstellung des Bereichs. + +### Changed +- Die Auswahl „Erledigte Aufgaben löschen nach …“ und ihr Erklärsatz im Reiter „Erledigt“ entfallen; die Fristen stehen gesammelt unter Aufbewahrung. „Erledigte leeren“ fragt nicht mehr nach, weil sich alles zurückholen lässt. +- **Achtung:** Der Papierkorb der Notizen und abgehakte Einträge in Einkaufs- und Checklisten werden jetzt nach der Frist automatisch geleert (Standard 30 bzw. 7 Tage ab dem Update). Wer das nicht möchte, stellt unter Aufbewahrung „nie“ ein. + ## [0.42.0] - 2026-11-01 ### Added @@ -692,7 +704,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...dev +[0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0 [0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0 [0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0 [0.40.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...v0.40.1 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 168c36b..42da663 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -34,7 +34,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` | | DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung | | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) | -| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`, `doneRetentionDays` (0, 7, 30, 90 oder 365; erledigte Aufgaben löschen nach so vielen Tagen, 0 = nie). `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | +| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`, `retention` (`doneTasks`, `taskTrash`, `triageTrash`, `noteTrash`, `listDone`: Tage aus 7, 14, 30, 90, 365, 730 oder 0 = nie; immer alle fünf, `400` bei anderen Werten oder bei 0, wenn der Server „nie“ abgeschaltet hat), `voiceInput` (`""` = Whisper-Server, `browser`), `doneRetentionDays` (älteres Einzelfeld für `retention.doneTasks`). `GET /auth/me` liefert dazu `retention` und `retentionNeverAllowed`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | @@ -92,7 +92,7 @@ MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, | `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` | | `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) | | `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) | -| `delete_task` | `tasks:write` | Aufgabe endgültig löschen (z. B. versehentlich angelegte) | +| `delete_task` | `tasks:write` | Aufgabe löschen (z. B. versehentlich angelegte); sie kommt in den Papierkorb der Aufgaben | | `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen | | `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) | | `get_list` | `lists:read` | Liste mit Einträgen | @@ -132,12 +132,16 @@ Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnte |---|---|---| | 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`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` | -| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren | +| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen: eine Aufgabe kommt in den Papierkorb der Aufgaben, ein Gedanke aus Posteingang oder Triage-Papierkorb ist endgültig weg. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` | +| DELETE | `/thoughts/discarded/purge` | Papierkorb der Triage (verworfene Gedanken) leeren | | GET | `/triage/deck` | Posteingang (max. 100) | | PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` | | GET | `/tasks?taskListId=&status=&due=` | Aufgaben (optional einer Liste; Filter siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)) | -| POST | `/tasks/clear-done` | Erledigte Aufgaben endgültig löschen (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` | +| POST | `/tasks/clear-done` | Erledigte Aufgaben in den Papierkorb der Aufgaben (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` | +| GET | `/tasks/trash?taskListId=` | Papierkorb der Aufgaben (einer Liste, sonst alle sichtbaren): `id`, `content`, `taskListId`, `isCompleted`, `trashedAt`, `trashedBy` (Name, leer = automatisch), `canEdit` | +| POST | `/tasks/trash/{id}/restore` | Aufgabe unverändert zurückholen (mit Zuweisungen; eine gelöschte Liste macht sie zur eigenen Aufgabe ihres Erstellers) → Aufgabe | +| DELETE | `/tasks/trash/{id}` | Endgültig löschen (Bearbeitungsrecht auf die Liste, sonst `403`) | +| POST | `/tasks/trash/empty` | Papierkorb leeren (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` | | 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 | | POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` | diff --git a/Doku/architecture.md b/Doku/architecture.md index f5c1e22..662de87 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -78,6 +78,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB | | `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) | | `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB | +| `RETENTION_ALLOW_NEVER` | leer | `false` verbietet „nie“ bei den Aufbewahrungsfristen (Kontoeinstellungen → Aufbewahrung); wer „nie“ gewählt hatte, bekommt die Voreinstellung des Bereichs. `true` erlaubt es fest. Leer: Schalter in Administration → Systemeinstellungen (Standard: erlaubt) | | `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser | | `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt | | `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus | diff --git a/README.md b/README.md index 4d09f95..9495778 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.42.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.43.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. @@ -40,7 +40,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * 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 oder über große Knöpfe unter der Karte; die Warteschlange klappt bei Bedarf auf. * **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang und lassen sich direkt danach mit einem Tipp zur Aufgabe oder auf eine Liste schicken. -* Papierkorb mit Wiederherstellen. +* Papierkörbe mit Wiederherstellen für verworfene Gedanken, gelöschte Aufgaben und Notizen; unter Kontoeinstellungen → Aufbewahrung legt jede Person fest, nach wie vielen Tagen Erledigtes und Gelöschtes aufgeräumt wird.

MindSift Triage am Smartphone, helles Theme diff --git a/docker-compose.yml b/docker-compose.yml index 9cd80ca..d4aea3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - TRUSTED_PROXIES=${TRUSTED_PROXIES:-private} + - RETENTION_ALLOW_NEVER=${RETENTION_ALLOW_NEVER:-} - LOGIN_PROTECTION=${LOGIN_PROTECTION:-true} - LOG_LEVEL=${LOG_LEVEL:-info} - DEBUG=${DEBUG:-false} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 68a4e97..9f83596 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.42.0", + "version": "0.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.42.0", + "version": "0.43.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 59ab709..fbc8bd2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.42.0", + "version": "0.43.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b733e86..3f645c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { LogOut, ChevronDown, RotateCcw, + Trash2, Palette, HelpCircle, Plus, @@ -73,6 +74,7 @@ import { CommandPalette } from './components/CommandPalette'; import { AppFooter } from './components/AppFooter'; import { ThemeQuickPicker } from './components/ThemeQuickPicker'; import { AboutModal } from './components/AboutModal'; +import { TriageTrash } from './features/triage/TriageTrash'; import { UpdateAvailableModal } from './components/UpdateAvailableModal'; import { useUpdateCheck } from './lib/useUpdateCheck'; import { consumePendingShares, formatSharedThought } from './lib/shareTarget'; @@ -191,6 +193,7 @@ export const App: React.FC = () => { const [showProfileMenu, setShowProfileMenu] = useState(false); const serverOnline = useServerOnline(); const [showAbout, setShowAbout] = useState(false); + const [showTriageTrash, setShowTriageTrash] = useState(false); const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck(); const dropdownRef = useRef(null); @@ -314,6 +317,13 @@ export const App: React.FC = () => { badge: stats.inboxCount, onClick: () => refreshAll(), }, + { + id: 'triage-trash', + label: 'Papierkorb', + icon: , + badge: stats.discardedCount, + onClick: () => setShowTriageTrash(true), + }, ], }, { @@ -394,7 +404,7 @@ export const App: React.FC = () => { }, ]); } - }, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]); + }, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount, stats.discardedCount]); // Mobile Web-App (PWA) Installation State const [isStandaloneApp, setIsStandaloneApp] = useState(() => { @@ -1275,6 +1285,8 @@ export const App: React.FC = () => { onDeckChange={refreshAll} onOpenDocument={handleOpenDocument} onFocusCapture={handleTriggerAddButton} + discardedCount={stats.discardedCount} + onOpenTrash={() => setShowTriageTrash(true)} /> )} @@ -1886,6 +1898,7 @@ export const App: React.FC = () => { /> {showAbout && setShowAbout(false)} />} + {showTriageTrash && setShowTriageTrash(false)} onChanged={refreshAll} />} {availableUpdate && } ); diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index d75a807..2a4a83c 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -264,7 +264,7 @@ export const HelpView: React.FC = ({ onBack }) => { Rückverweise: Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit [[Titel]] verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.

  • - Papierkorb: Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war. + Papierkorb: Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war. Nach der Frist aus Kontoeinstellungen → Aufbewahrung (Standard 30 Tage) wird sie endgültig gelöscht. Gelöschte Aufgaben liegen im Reiter „Papierkorb“ der Aufgaben, verworfene Gedanken im Papierkorb der Triage.
  • diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index cfdd4e4..d7cec05 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -158,6 +158,8 @@ export const AdminSettingsView: React.FC = ({ backupEnabled: Boolean(sys.backupEnabled), backupTime: sys.backupTime || '03:00', backupRetentionDays: sys.backupRetentionDays || 3, + retentionAllowNever: sys.retentionAllowNever ?? true, + retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv), }); } } catch (err: any) { @@ -1315,6 +1317,23 @@ export const AdminSettingsView: React.FC = ({ /> + {/* Retention: may people keep finished and deleted things for good? */} +
    +
    +

    Dauerhaftes Aufbewahren erlauben

    +

    + Eingeschaltet kann jede Person unter Kontoeinstellungen → Aufbewahrung „nie“ wählen, also Erledigtes und + Gelöschtes behalten. Ausgeschaltet gilt dort für „nie“ die Voreinstellung des Bereichs (z. B. 30 Tage). + {settings.retentionNeverViaEnv && ' Festgelegt per RETENTION_ALLOW_NEVER.'} +

    +
    + handleSaveSystemSettings({ retentionAllowNever })} + /> +
    + {/* Who may register without an invite */} {(() => { const mode = settings.registrationMode || 'closed'; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 418a45b..0538663 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -19,8 +19,9 @@ import { Copy, Archive, Mic, + Clock, } from 'lucide-react'; -import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types'; +import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, Retention, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; import { ServerBackupsCard } from './ServerBackupsCard'; import { saveBlob } from '../../lib/download'; @@ -68,7 +69,25 @@ interface UserSettingsViewProps { onBack: () => void; } -type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'webhooks' | 'data'; +// Kontoeinstellungen → Aufbewahrung: the areas and the delays offered (storage.RetentionChoices) +const RETENTION_AREAS: { key: keyof Retention; title: string; text: string; verb: string }[] = [ + { key: 'doneTasks', title: 'Erledigte Aufgaben', text: 'Wandern danach in den Papierkorb der Aufgaben.', verb: 'in den Papierkorb nach' }, + { key: 'taskTrash', title: 'Papierkorb der Aufgaben', text: 'Gelöschte und geleerte Aufgaben.', verb: 'endgültig löschen nach' }, + { key: 'triageTrash', title: 'Papierkorb der Triage', text: 'Beim Sortieren verworfene Gedanken.', verb: 'endgültig löschen nach' }, + { key: 'noteTrash', title: 'Papierkorb der Notizen', text: 'Gelöschte Notizen im Workspace.', verb: 'endgültig löschen nach' }, + { key: 'listDone', title: 'Abgehakte Listeneinträge', text: 'In Einkaufs- und Checklisten.', verb: 'löschen nach' }, +]; +const RETENTION_OPTIONS = [ + { days: 7, label: '7 Tagen' }, + { days: 14, label: '14 Tagen' }, + { days: 30, label: '30 Tagen' }, + { days: 90, label: '90 Tagen' }, + { days: 365, label: 'einem Jahr' }, + { days: 730, label: 'zwei Jahren' }, + { days: 0, label: 'nie' }, +]; + +type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'retention' | 'webhooks' | 'data'; export function buildNotificationSettings(user: User): NotificationSettings { const hasLegacy = Array.isArray(user.alarmChannels); @@ -140,6 +159,22 @@ export const UserSettingsView: React.FC = ({ useEffect(() => { void loadVoiceConfig().then(() => setVoiceChoice(voiceChoiceAvailable())); }, []); + // Retention: one delay per area (storage/retention.go); "nie" only when the server allows it + const [retentionSaving, setRetentionSaving] = useState(false); + const [retentionError, setRetentionError] = useState(null); + const handleChangeRetention = async (key: keyof Retention, days: number) => { + if (!currentUser.retention) return; + setRetentionSaving(true); + setRetentionError(null); + try { + onUserUpdated(await api.updateSettings({ retention: { ...currentUser.retention, [key]: days } })); + } catch (err: any) { + setRetentionError(err?.message || 'Speichern fehlgeschlagen'); + } finally { + setRetentionSaving(false); + } + }; + const handleChangeVoiceInput = async (next: '' | 'browser') => { setVoiceSaving(true); try { @@ -675,6 +710,21 @@ export const UserSettingsView: React.FC = ({ )} + + - {filter === 'discarded' && safeDiscarded.length > 0 && ( - @@ -2773,68 +2762,51 @@ export const TasksView: React.FC = ({ )} - {/* What happens to completed tasks; the delay of the list owner counts */} - {filter === 'done' && ( -
    - {currentSelectedList && currentSelectedList.isOwner === false ? ( - - Erledigte Aufgaben dieser Liste räumt die Einstellung von {currentSelectedList.ownerName || 'der Eigentümerin bzw. dem Eigentümer'} auf. - - ) : ( - <> - Erledigte Aufgaben löschen - - - {doneRetentionDays > 0 ? '(gezählt ab dem Abhaken; gilt für deine eigenen Listen)' : '(bleiben, bis du sie leerst)'} - - - )} -
    - )} - {loading ? (
    Lade Aufgaben…
    ) : filter === 'discarded' ? (
    - {safeDiscarded.length === 0 ? ( -
    - Keine verworfenen Gedanken im Papierkorb. -
    + {taskTrash.length === 0 ? ( +
    Der Papierkorb ist leer.
    ) : ( - safeDiscarded.map((item) => ( -
    -
    - -

    {item.content}

    + taskTrash.map((item) => { + const listOfItem = item.taskListId ? safeTaskLists.find((l) => l.id === item.taskListId) : undefined; + return ( +
    +
    + +
    +

    {item.content}

    +

    + {selectedListId === 'all' && ( + <> + {listOfItem ? `${listOfItem.icon || '📋'} ${listOfItem.title}` : 'Persönlich'} + {' · '} + + )} + gelöscht {formatDeletedAt(item.trashedAt)} + {item.trashedBy ? ` von ${item.trashedBy}` : ' (automatisch)'} +

    +
    +
    + {item.canEdit && ( +
    + + +
    + )}
    -
    - - -
    -
    - )) + ); + }) )}
    ) : ( diff --git a/frontend/src/features/triage/TriageDeck.tsx b/frontend/src/features/triage/TriageDeck.tsx index 2c4847d..ff0c62f 100644 --- a/frontend/src/features/triage/TriageDeck.tsx +++ b/frontend/src/features/triage/TriageDeck.tsx @@ -20,6 +20,7 @@ import { ArrowLeft, ArrowRight, ChevronDown, + Trash2, } from 'lucide-react'; import { SharedList, Thought } from '../../lib/types'; import { addDays, formatDue } from '../../lib/due'; @@ -32,6 +33,9 @@ interface TriageDeckProps { onDeckChange: () => void; onOpenDocument: (docId: string) => void; onFocusCapture: () => void; + // The triage's own trash (TriageTrash): thoughts thrown away while sorting + discardedCount: number; + onOpenTrash: () => void; } interface SwipeableQueueItemProps { @@ -219,6 +223,8 @@ export const TriageDeck: React.FC = ({ lists, onDeckChange, onFocusCapture, + discardedCount, + onOpenTrash, }) => { const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer'); const singleWritableList = writableLists.length === 1 ? writableLists[0] : null; @@ -469,6 +475,13 @@ export const TriageDeck: React.FC = ({ Letzte Aktion rückgängig )} + + {discardedCount > 0 && ( + + )}
    ); @@ -488,15 +501,24 @@ export const TriageDeck: React.FC = ({ - {lastTriaged && ( - - )} +
    + {lastTriaged && ( + + )} + {/* Phones reach the trash through the menu */} + {discardedCount > 0 && ( + + )} +
    {/* Card Stack Container */} diff --git a/frontend/src/features/triage/TriageTrash.tsx b/frontend/src/features/triage/TriageTrash.tsx new file mode 100644 index 0000000..1667554 --- /dev/null +++ b/frontend/src/features/triage/TriageTrash.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react'; +import { Loader2, RotateCcw, Trash2 } from 'lucide-react'; +import { api, triggerHaptic } from '../../lib/api'; +import { formatDeletedAt } from '../../lib/due'; +import type { Thought } from '../../lib/types'; +import { ReadingModal } from '../../components/ReadingModal'; + +interface TriageTrashProps { + onClose: () => void; + // Something was restored or deleted: counters and the deck change + onChanged: () => void; +} + +// The triage's own trash: thoughts thrown away while sorting. Back into the Quick Dump, or deleted for +// good (one by one, all at once, or after the delay from Kontoeinstellungen → Aufbewahrung). Deleting +// for good takes a second tap on the same button, like the notes trash. +export const TriageTrash: React.FC = ({ onClose, onChanged }) => { + const [items, setItems] = useState(null); + const [confirmId, setConfirmId] = useState(null); + + const load = async () => { + const list = await api.getThoughts('discarded').catch(() => []); + setItems(Array.isArray(list) ? list : []); + }; + + useEffect(() => { + void load(); + }, []); + + const restore = async (item: Thought) => { + triggerHaptic(15); + await api.triageAction(item.id, 'restore'); + await load(); + onChanged(); + }; + + const purge = async (id: string) => { + if (confirmId !== id) { + setConfirmId(id); + return; + } + triggerHaptic(20); + setConfirmId(null); + if (id === '__all__') await api.purgeDiscarded(); + else await api.deleteThought(id); + await load(); + onChanged(); + }; + + return ( + + {items === null ? ( +
    + +
    + ) : items.length === 0 ? ( +

    Keine verworfenen Gedanken.

    + ) : ( +
    +
    + {items.map((item) => ( +
    +
    +

    {item.content}

    + {item.triagedAt &&

    verworfen {formatDeletedAt(item.triagedAt)}

    } +
    +
    + + +
    +
    + ))} +
    +
    + +
    +
    + )} +
    + ); +}; diff --git a/frontend/src/features/triage/__tests__/TriageDeck.lists.test.tsx b/frontend/src/features/triage/__tests__/TriageDeck.lists.test.tsx index de48156..cc46f6f 100644 --- a/frontend/src/features/triage/__tests__/TriageDeck.lists.test.tsx +++ b/frontend/src/features/triage/__tests__/TriageDeck.lists.test.tsx @@ -71,6 +71,8 @@ describe('TriageDeck single-list shortcut', () => { onDeckChange={onDeckChange} onOpenDocument={vi.fn()} onFocusCapture={vi.fn()} + discardedCount={0} + onOpenTrash={vi.fn()} /> ); @@ -100,6 +102,8 @@ describe('TriageDeck single-list shortcut', () => { onDeckChange={onDeckChange} onOpenDocument={vi.fn()} onFocusCapture={vi.fn()} + discardedCount={0} + onOpenTrash={vi.fn()} /> ); @@ -123,6 +127,8 @@ describe('TriageDeck single-list shortcut', () => { onDeckChange={onDeckChange} onOpenDocument={vi.fn()} onFocusCapture={vi.fn()} + discardedCount={0} + onOpenTrash={vi.fn()} /> ); @@ -149,6 +155,8 @@ describe('TriageDeck single-list shortcut', () => { onDeckChange={onDeckChange} onOpenDocument={vi.fn()} onFocusCapture={vi.fn()} + discardedCount={0} + onOpenTrash={vi.fn()} /> ); @@ -171,6 +179,8 @@ describe('TriageDeck single-list shortcut', () => { onDeckChange={onDeckChange} onOpenDocument={vi.fn()} onFocusCapture={vi.fn()} + discardedCount={0} + onOpenTrash={vi.fn()} /> ); diff --git a/frontend/src/features/workspace/TrashView.tsx b/frontend/src/features/workspace/TrashView.tsx index c5ccd54..d3888d0 100644 --- a/frontend/src/features/workspace/TrashView.tsx +++ b/frontend/src/features/workspace/TrashView.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react'; import { TrashedDocument } from '../../lib/types'; import { CountBadge } from '../../components/CountBadge'; +import { formatDeletedAt } from '../../lib/due'; interface TrashViewProps { items: TrashedDocument[]; @@ -12,12 +13,6 @@ interface TrashViewProps { onBackMobile: () => void; } -const formatDeletedAt = (iso: string) => { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ''; - return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); -}; - // Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second // tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch. export const TrashView: React.FC = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9458425..1eebfdc 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,8 @@ import { DashboardStats, Document, TrashedDocument, + TrashedTask, + Retention, JournalDay, PushDevice, BackupFile, @@ -302,6 +304,7 @@ export const api = { notificationSettings?: NotificationSettings; doneRetentionDays?: number; voiceInput?: '' | 'browser'; + retention?: Retention; }): Promise { return request('/api/v1/auth/settings', { method: 'PATCH', @@ -479,6 +482,24 @@ export const api = { return request<{ deleted: number }>('/api/v1/tasks/clear-done', { method: 'POST', body: JSON.stringify({ taskListId }) }); }, + // Task trash: deleted tasks, cleared and automatically cleaned-up completed ones + async getTaskTrash(taskListId = ''): Promise { + const q = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : ''; + return request(`/api/v1/tasks/trash${q}`); + }, + + async restoreTrashedTask(id: string): Promise { + return request(`/api/v1/tasks/trash/${id}/restore`, { method: 'POST' }); + }, + + async purgeTrashedTask(id: string): Promise<{ deleted: boolean }> { + return request<{ deleted: boolean }>(`/api/v1/tasks/trash/${id}`, { method: 'DELETE' }); + }, + + async emptyTaskTrash(taskListId: string): Promise<{ deleted: number }> { + return request<{ deleted: number }>('/api/v1/tasks/trash/empty', { method: 'POST', body: JSON.stringify({ taskListId }) }); + }, + async purgeDiscarded(): Promise<{ purged: number }> { return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' }); }, diff --git a/frontend/src/lib/due.ts b/frontend/src/lib/due.ts index 30b5be9..fd2e0ed 100644 --- a/frontend/src/lib/due.ts +++ b/frontend/src/lib/due.ts @@ -48,3 +48,10 @@ export const formatDue = (due: string | undefined, now: Date = new Date()): stri else label = `${pad(d)}.${pad(m)}.${y}`; return time ? `${label}, ${time}` : label; }; + +/** When something went to a trash, e.g. "01.11.2026, 14:05". */ +export const formatDeletedAt = (iso: string) => { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); +}; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 2115ce9..b688bf7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -115,6 +115,9 @@ export interface User { doneRetentionDays?: number; // Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition voiceInput?: '' | 'browser'; + // How long finished and deleted things are kept, in days (0 = for good; only when allowed) + retention?: Retention; + retentionNeverAllowed?: boolean; organizationId?: string; createdAt: string; } @@ -293,6 +296,9 @@ export interface SystemSettings { backupEnabled?: boolean; backupTime?: string; backupRetentionDays?: number; + // People may keep finished and deleted things for good ("nie"); fixed when set via RETENTION_ALLOW_NEVER + retentionAllowNever?: boolean; + retentionNeverViaEnv?: boolean; } /** One file of a server backup listing. */ @@ -382,6 +388,26 @@ export interface JournalDay { empty: boolean; } +/** Delays in days per area (Kontoeinstellungen → Aufbewahrung); 0 = keep for good. */ +export interface Retention { + doneTasks: number; + taskTrash: number; + triageTrash: number; + noteTrash: number; + listDone: number; +} + +/** A deleted task waiting in the task trash. */ +export interface TrashedTask { + id: string; + content: string; + taskListId: string; + isCompleted: boolean; + trashedAt: string; + trashedBy: string; + canEdit: boolean; +} + export interface TrashedDocument { id: string; title: string; diff --git a/internal/api/done_cleanup.go b/internal/api/done_cleanup.go index c659699..c30a94a 100644 --- a/internal/api/done_cleanup.go +++ b/internal/api/done_cleanup.go @@ -2,16 +2,19 @@ package api import ( "encoding/json" + "errors" "log/slog" "net/http" "time" + "github.com/go-chi/chi/v5" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/storage" ) -// startDoneCleanupTicker deletes completed tasks whose delay ran out (storage/done_cleanup.go), once -// shortly after the start and then every hour. +// startDoneCleanupTicker runs the retention delays (storage/retention.go): completed tasks into the +// task trash, the trashes and ticked-off list items emptied; once shortly after the start and then +// every hour. func (s *Server) startDoneCleanupTicker() { time.Sleep(2 * time.Minute) s.runDoneCleanup(time.Now()) @@ -22,16 +25,27 @@ func (s *Server) startDoneCleanupTicker() { } } +// allowNeverRetention: RETENTION_ALLOW_NEVER if set, else the admin's switch in the system settings. +func (s *Server) allowNeverRetention() bool { + if s.cfg.RetentionAllowNever != nil { + return *s.cfg.RetentionAllowNever + } + return s.store.RetentionAllowNeverSetting() +} + func (s *Server) runDoneCleanup(now time.Time) { - deleted, err := s.store.PurgeExpiredDoneTasks(now) + res, err := s.store.PurgeExpired(now, s.allowNeverRetention()) if err != nil { - slog.Error("Aufräumen erledigter Aufgaben fehlgeschlagen", "error", err) - return + slog.Error("Aufräumen nach Aufbewahrungsfristen fehlgeschlagen", "error", err) } - if len(deleted) > 0 { - slog.Info("Erledigte Aufgaben aufgeräumt", "count", len(deleted)) + if n := len(res.TasksToTrash) + len(res.TrashedTasks) + res.Discarded + len(res.Notes) + len(res.ListItems); n > 0 { + slog.Info("Aufbewahrungsfristen angewendet", "erledigte_in_papierkorb", len(res.TasksToTrash), "papierkorb_aufgaben", len(res.TrashedTasks), + "triage", res.Discarded, "notizen", len(res.Notes), "listeneintraege", len(res.ListItems)) + } + s.broadcastDeletedTasks(res.TasksToTrash) + for _, it := range res.ListItems { + s.wsHub.BroadcastListEvent(it.ListID, "ITEM_DELETED", map[string]string{"itemId": it.ID, "listId": it.ListID}) } - s.broadcastDeletedTasks(deleted) } // broadcastDeletedTasks tells everyone who saw the tasks; there are no watcher notifications for this. @@ -42,7 +56,8 @@ func (s *Server) broadcastDeletedTasks(deleted []storage.DeletedTask) { } } -// handleClearDoneTasks: "Erledigte leeren" for one task list (taskListId) or all the user can edit. +// handleClearDoneTasks: "Erledigte leeren" (into the task trash) for one task list (taskListId) or all +// the user can edit. func (s *Server) handleClearDoneTasks(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { @@ -57,3 +72,59 @@ func (s *Server) handleClearDoneTasks(w http.ResponseWriter, r *http.Request) { s.broadcastDeletedTasks(deleted) writeJSON(w, http.StatusOK, map[string]int{"deleted": len(deleted)}) } + +// --- Task trash (storage/task_trash.go) --- + +// handleGetTaskTrash: GET /tasks/trash?taskListId= – the trashed tasks the user can see. +func (s *Server) handleGetTaskTrash(w http.ResponseWriter, r *http.Request) { + items, err := s.store.GetTaskTrash(auth.GetUserID(r.Context()), r.URL.Query().Get("taskListId")) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, items) +} + +// handleRestoreTrashedTask puts a task back and tells everyone who sees it. +func (s *Server) handleRestoreTrashedTask(w http.ResponseWriter, r *http.Request) { + task, err := s.store.RestoreTrashedTask(auth.GetUserID(r.Context()), chi.URLParam(r, "id")) + if err != nil { + writeTaskTrashError(w, err) + return + } + s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task) + writeJSON(w, http.StatusOK, task) +} + +func (s *Server) handlePurgeTrashedTask(w http.ResponseWriter, r *http.Request) { + if err := s.store.PurgeTrashedTask(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil { + writeTaskTrashError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) +} + +// handleEmptyTaskTrash: POST /tasks/trash/empty {taskListId} – one list or all the user may edit. +func (s *Server) handleEmptyTaskTrash(w http.ResponseWriter, r *http.Request) { + var req struct { + TaskListID string `json:"taskListId"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + ids, err := s.store.EmptyTaskTrash(auth.GetUserID(r.Context()), req.TaskListID) + if err != nil { + writeTaskTrashError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]int{"deleted": len(ids)}) +} + +func writeTaskTrashError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, storage.ErrNotInTaskTrash): + writeError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, storage.ErrTaskListReadOnly): + writeError(w, http.StatusForbidden, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } +} diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index c5dbda0..b0c0068 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -340,6 +340,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true" out.RegistrationVerifyViaEnv = true } + if s.cfg.RetentionAllowNever != nil { + allow := *s.cfg.RetentionAllowNever + out.RetentionAllowNever = &allow + out.RetentionNeverViaEnv = true + } out.EmailLinksAvailable = s.emailLinksAvailable() return &out } @@ -372,6 +377,10 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques settings.RegistrationVerifyEmail = cur.RegistrationVerifyEmail } } + if s.cfg.RetentionAllowNever != nil { + settings.RetentionAllowNever = nil // fixed by RETENTION_ALLOW_NEVER, the stored value stays + } + settings.RetentionNeverViaEnv = false previousMode := s.store.SharingMode() if err := s.store.UpdateSystemSettings(&settings); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -384,7 +393,11 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques return } } - writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings)) + saved, _ := s.store.GetSystemSettings() + if saved == nil { + saved = &settings + } + writeJSON(w, http.StatusOK, s.settingsForBrowser(saved)) } func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 7c1ed25..28f7000 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -404,8 +404,7 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { stats, _ := s.store.GetDashboardStats(userID) ns := s.store.NotificationSettingsOf(userID) user.NotificationSettings = &ns - user.DoneRetentionDays = s.store.DoneRetentionDays(userID) - user.VoiceInput = s.store.VoiceInput(userID) + s.fillUserPreferences(user) writeJSON(w, http.StatusOK, map[string]interface{}{ "user": user, "stats": stats, @@ -519,6 +518,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request DoneRetentionDays *int `json:"doneRetentionDays"` // Voice input: "" (Whisper server, the default) or "browser" VoiceInput *string `json:"voiceInput"` + // All retention delays at once (storage/retention.go) + Retention *domain.Retention `json:"retention"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -606,6 +607,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request } } if req.DoneRetentionDays != nil { + if *req.DoneRetentionDays == 0 && !s.allowNeverRetention() { + writeError(w, http.StatusBadRequest, storage.ErrRetentionNever.Error()) + return + } if err := s.store.SetDoneRetentionDays(userID, *req.DoneRetentionDays); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -617,10 +622,15 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request return } } + if req.Retention != nil { + if err := s.store.SetRetention(userID, *req.Retention, s.allowNeverRetention()); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } ns := s.store.NotificationSettingsOf(userID) u.NotificationSettings = &ns - u.DoneRetentionDays = s.store.DoneRetentionDays(userID) - u.VoiceInput = s.store.VoiceInput(userID) + s.fillUserPreferences(u) writeJSON(w, http.StatusOK, u) } @@ -756,3 +766,13 @@ func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) } writeJSON(w, http.StatusOK, users) } + +// fillUserPreferences adds the settings kept outside the user row's own fields. +func (s *Server) fillUserPreferences(u *domain.User) { + allowNever := s.allowNeverRetention() + r := s.store.RetentionOf(u.ID, allowNever) + u.Retention = &r + u.RetentionNeverAllowed = allowNever + u.DoneRetentionDays = r.DoneTasks + u.VoiceInput = s.store.VoiceInput(u.ID) +} diff --git a/internal/api/mcp.go b/internal/api/mcp.go index f538550..aeb8394 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -550,7 +550,7 @@ var mcpTools = []mcpTool{ }, { Name: "delete_task", - Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.", + Description: "Delete a task, e.g. one created by mistake: it goes to the task trash, where the user can restore it. Prefer complete_task for work that is done.", Scope: storage.ScopeTasksWrite, Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}), run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) { diff --git a/internal/api/server.go b/internal/api/server.go index 4397fd3..44dd677 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -207,6 +207,10 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask) r.Post("/api/v1/tasks/reorder", s.handleReorderTasks) r.Post("/api/v1/tasks/clear-done", s.handleClearDoneTasks) + r.Get("/api/v1/tasks/trash", s.handleGetTaskTrash) + r.Post("/api/v1/tasks/trash/empty", s.handleEmptyTaskTrash) + r.Post("/api/v1/tasks/trash/{id}/restore", s.handleRestoreTrashedTask) + r.Delete("/api/v1/tasks/trash/{id}", s.handlePurgeTrashedTask) r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks) // Task Lists & Shares diff --git a/internal/config/config.go b/internal/config/config.go index 30c2b34..4ec721b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,9 @@ type Config struct { // Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login Legal LegalTexts + // RetentionAllowNever: RETENTION_ALLOW_NEVER=true/false fixes whether people may keep finished and + // deleted things for good; unset (nil) leaves it to the system settings + RetentionAllowNever *bool // Speech recognition, configured only by environment (STT_*) Speech SpeechConfig @@ -254,6 +257,7 @@ func Load() *Config { DemoMode: demoMode, DemoResetMinutes: demoResetMinutes(), TrustedProxies: getEnv("TRUSTED_PROXIES", "private"), + RetentionAllowNever: envBoolPtr("RETENTION_ALLOW_NEVER"), DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false", SMTPHost: smtpHost, SMTPPort: smtpPort, @@ -369,3 +373,16 @@ func boolEnvOrEmpty(key string) string { } return "" } + +// envBoolPtr reads true/false; nil when unset or anything else. +func envBoolPtr(key string) *bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "true", "1", "yes": + v := true + return &v + case "false", "0", "no": + v := false + return &v + } + return nil +} diff --git a/internal/domain/models.go b/internal/domain/models.go index 4123f42..36d2355 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -145,6 +145,10 @@ type User struct { DoneRetentionDays int `json:"doneRetentionDays"` // VoiceInput: "" uses the server's Whisper (when set up), "browser" the browser's recognition VoiceInput string `json:"voiceInput"` + // Retention: how long finished and deleted things are kept (storage/retention.go); + // RetentionNeverAllowed: whether "nie" may be chosen on this server + Retention *Retention `json:"retention,omitempty"` + RetentionNeverAllowed bool `json:"retentionNeverAllowed"` } // NotificationSettings: which events reach a user over which channel, the daily summary and a pause. @@ -410,6 +414,10 @@ type SystemSettings struct { BackupEnabled bool `json:"backupEnabled"` BackupTime string `json:"backupTime"` BackupRetentionDays int `json:"backupRetentionDays"` + // RetentionAllowNever: people may keep finished and deleted things for good ("nie"); nil in an + // update leaves it unchanged. RetentionNeverViaEnv: fixed by RETENTION_ALLOW_NEVER, not editable. + RetentionAllowNever *bool `json:"retentionAllowNever,omitempty"` + RetentionNeverViaEnv bool `json:"retentionNeverViaEnv,omitempty"` } // UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole @@ -682,3 +690,13 @@ type FolderShare struct { CreatedBy string `json:"createdBy"` CreatedAt string `json:"createdAt"` } + +// Retention: delays in days for completed tasks, the task, triage and notes trash and ticked-off list +// items; 0 = keep for good. +type Retention struct { + DoneTasks int `json:"doneTasks"` + TaskTrash int `json:"taskTrash"` + TriageTrash int `json:"triageTrash"` + NoteTrash int `json:"noteTrash"` + ListDone int `json:"listDone"` +} diff --git a/internal/storage/done_cleanup.go b/internal/storage/done_cleanup.go index 57a9e54..97ce472 100644 --- a/internal/storage/done_cleanup.go +++ b/internal/storage/done_cleanup.go @@ -1,23 +1,18 @@ package storage import ( - "errors" "strings" "time" ) -// Completed tasks are deleted a while after they were ticked off, so the "Erledigt" tab does not fill -// up for good. Every user chooses the delay (done_retention_days, 0 = never); for tasks in a task list -// the owner's choice counts, for personal tasks the user's own. thoughts.completed_at is kept by two -// triggers, so every way of completing a task (app, Kanban, note checkbox, API) sets it. - -// DoneRetentionChoices are the delays offered in the app, in days; 0 keeps completed tasks. -var DoneRetentionChoices = []int{0, 7, 30, 90, 365} +// Completed tasks move to the task trash (task_trash.go) a while after they were ticked off, so the +// "Erledigt" tab does not fill up for good. Every user chooses the delay (done_retention_days, see +// retention.go); for tasks in a task list the owner's choice counts, for personal tasks the user's own. +// thoughts.completed_at is kept by two triggers, so every way of completing a task (app, Kanban, note +// checkbox, API) sets it. const DefaultDoneRetentionDays = 30 -var ErrInvalidRetention = errors.New("Ungültige Frist für erledigte Aufgaben (erlaubt: 0, 7, 30, 90, 365 Tage)") - func (s *Store) migrateDoneCleanup() error { if err := s.addColumnIfMissing("users", "done_retention_days", "INTEGER NOT NULL DEFAULT 30"); err != nil { return err @@ -47,20 +42,16 @@ func (s *Store) DoneRetentionDays(userID string) int { return days } -// SetDoneRetentionDays stores one of DoneRetentionChoices; other values are refused. +// SetDoneRetentionDays stores one of RetentionChoices; other values are refused. func (s *Store) SetDoneRetentionDays(userID string, days int) error { - ok := false - for _, d := range DoneRetentionChoices { - ok = ok || d == days - } - if !ok { + if !validRetention(days) { return ErrInvalidRetention } _, err := s.db.Exec(`UPDATE users SET done_retention_days = ? WHERE id = ?`, days, userID) return err } -// DeletedTask: what callers need to tell everyone who saw a deleted task. +// DeletedTask: what callers need to tell everyone who saw a task that went to the trash. type DeletedTask struct { ID, UserID, TaskListID string } @@ -68,27 +59,29 @@ type DeletedTask struct { // Completed tasks: a kept task, a converted task or a task linked to a note checkbox const completedTaskSQL = `t.is_completed = 1 AND (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))` -// PurgeExpiredDoneTasks deletes completed tasks whose delay has run out at now: the task list owner's -// delay, or the user's own for personal tasks. A note checkbox linked to such a task stays as a plain -// checkbox (the link lives on the task). -func (s *Store) PurgeExpiredDoneTasks(now time.Time) ([]DeletedTask, error) { +// PurgeExpiredDoneTasks moves completed tasks whose delay has run out at now into the task trash: the +// task list owner's delay, or the user's own for personal tasks. A note checkbox linked to such a task +// stays as a plain checkbox (the link lives on the task). Without allowNever, "never" counts as the +// default delay. +func (s *Store) PurgeExpiredDoneTasks(now time.Time, allowNever bool) ([]DeletedTask, error) { + def := neverAs(allowNever, DefaultDoneRetentionDays) rows, err := s.db.Query(` SELECT t.id, t.user_id, t.task_list_id FROM thoughts t LEFT JOIN task_lists tl ON tl.id = t.task_list_id AND t.task_list_id != '' JOIN users u ON u.id = COALESCE(tl.user_id, t.user_id) WHERE `+completedTaskSQL+` - AND t.completed_at != '' AND u.done_retention_days > 0 - AND t.completed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || u.done_retention_days || ' days')`, - now.UTC().Format("2006-01-02 15:04:05")) + AND t.completed_at != '' AND `+daysSQL("u.done_retention_days")+` > 0 + AND t.completed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || `+daysSQL("u.done_retention_days")+` || ' days')`, + def, sqlNow(now), def) if err != nil { return nil, err } - return s.deleteTasks(rows) + return s.trashTaskRows(rows, "") } -// ClearDoneTasks deletes the completed tasks of one task list ("" = all lists the user can see) that -// the user may delete: own personal tasks and tasks in lists they own or edit. +// ClearDoneTasks moves the completed tasks of one task list ("" = all lists the user can see) into the +// task trash, as far as the user may delete them: own personal tasks and tasks in lists they own or edit. func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error) { if taskListID != "" { if err := s.ensureTaskListWritable(userID, taskListID); err != nil { @@ -99,7 +92,7 @@ func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error) if err != nil { return nil, err } - return s.deleteTasks(rows) + return s.trashTaskRows(rows, userID) } lists, err := s.GetTaskLists(userID) if err != nil { @@ -122,15 +115,15 @@ func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error) if err != nil { return nil, err } - return s.deleteTasks(rows) + return s.trashTaskRows(rows, userID) } -// deleteTasks deletes the tasks the query rows name and returns them. -func (s *Store) deleteTasks(rows interface { +// trashTaskRows moves the tasks the query rows name into the task trash and returns them. +func (s *Store) trashTaskRows(rows interface { Next() bool Scan(dest ...any) error Close() error -}) ([]DeletedTask, error) { +}, byUserID string) ([]DeletedTask, error) { var out []DeletedTask for rows.Next() { var d DeletedTask @@ -140,7 +133,7 @@ func (s *Store) deleteTasks(rows interface { } _ = rows.Close() for _, d := range out { - if _, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, d.ID); err != nil { + if err := s.trashTask(d.ID, byUserID); err != nil { return nil, err } } diff --git a/internal/storage/done_cleanup_test.go b/internal/storage/done_cleanup_test.go index 1cda6d8..5d3eff2 100644 --- a/internal/storage/done_cleanup_test.go +++ b/internal/storage/done_cleanup_test.go @@ -82,7 +82,7 @@ func TestPurgeExpiredDoneTasks(t *testing.T) { benInAnnasList := doneTask(t, s, ben.ID, shared.ID, "in Annas Liste", "2026-11-01T08:00:00Z") openTask, _ := s.CreateThoughtWithDetails(anna.ID, "offen", "normal", "manual", "", "", "todo", "") - deleted, err := s.PurgeExpiredDoneTasks(now) + deleted, err := s.PurgeExpiredDoneTasks(now, true) if err != nil { t.Fatal(err) } @@ -94,7 +94,7 @@ func TestPurgeExpiredDoneTasks(t *testing.T) { } // Edge: exactly at the limit nothing happens yet edge := doneTask(t, s, anna.ID, "", "Grenze", "2026-11-15T00:30:00Z") - _, _ = s.PurgeExpiredDoneTasks(now) + _, _ = s.PurgeExpiredDoneTasks(now, true) if !exists(s, edge) { t.Fatal("a task completed exactly 30 days ago stays until the limit has passed") } diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 4f195cc..c8c0b23 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -36,6 +36,7 @@ var migrations = []migration{ {13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin}, {14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup}, {15, "Spracheingabe pro Person", (*Store).migrateVoiceInput}, + {16, "Aufbewahrung und Papierkorb für Aufgaben", (*Store).migrateRetention}, } func (s *Store) migrate() error { diff --git a/internal/storage/retention.go b/internal/storage/retention.go new file mode 100644 index 0000000..e3db2ac --- /dev/null +++ b/internal/storage/retention.go @@ -0,0 +1,239 @@ +package storage + +import ( + "errors" + "strconv" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// How long finished and deleted things are kept, chosen by everyone in the account settings +// ("Aufbewahrung"): completed tasks (then into the task trash), the task trash, the triage trash +// (discarded thoughts), the notes trash and ticked-off list items (deleted for good). In shared task +// lists, lists and notes the owner's choice counts. 0 keeps things for good; the admin can switch that +// off (RETENTION_ALLOW_NEVER or the system settings), then 0 counts as the area's default. +// +// Things that were already finished or deleted before this feature count from its update on +// (retention_since), so nothing disappeared at once. + +// RetentionChoices are the delays offered in the app, in days; 0 = never. +var RetentionChoices = []int{0, 7, 14, 30, 90, 365, 730} + +// Retention: the delays of one user, in days. +type Retention = domain.Retention + +// DefaultRetention: what everyone starts with, and what "never" becomes when it is not allowed. +var DefaultRetention = Retention{DoneTasks: DefaultDoneRetentionDays, TaskTrash: 30, TriageTrash: 14, NoteTrash: 30, ListDone: 7} + +var ( + ErrInvalidRetention = errors.New("Ungültige Frist (erlaubt: 7, 14, 30, 90, 365, 730 Tage oder nie)") + ErrRetentionNever = errors.New("Dauerhaftes Aufbewahren ist auf diesem Server abgeschaltet") +) + +// retentionFields maps each area to its users column. +var retentionFields = []struct { + column string + field func(r *Retention) *int +}{ + {"done_retention_days", func(r *Retention) *int { return &r.DoneTasks }}, + {"task_trash_days", func(r *Retention) *int { return &r.TaskTrash }}, + {"triage_trash_days", func(r *Retention) *int { return &r.TriageTrash }}, + {"note_trash_days", func(r *Retention) *int { return &r.NoteTrash }}, + {"list_done_days", func(r *Retention) *int { return &r.ListDone }}, +} + +func (s *Store) migrateRetention() error { + d := DefaultRetention + for _, c := range []struct { + column string + days int + }{{"task_trash_days", d.TaskTrash}, {"triage_trash_days", d.TriageTrash}, {"note_trash_days", d.NoteTrash}, {"list_done_days", d.ListDone}} { + if err := s.addColumnIfMissing("users", c.column, "INTEGER NOT NULL DEFAULT "+strconv.Itoa(c.days)); err != nil { + return err + } + } + return s.execInTx( + stmt(`CREATE TABLE IF NOT EXISTS trashed_tasks ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + task_list_id TEXT NOT NULL DEFAULT '', + owner_id TEXT NOT NULL, + content TEXT NOT NULL, + is_completed INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL, + assignees TEXT NOT NULL DEFAULT '[]', + trashed_at TEXT NOT NULL, + trashed_by TEXT NOT NULL DEFAULT '' + )`), + stmt(`CREATE INDEX IF NOT EXISTS idx_trashed_tasks_list ON trashed_tasks(task_list_id, trashed_at)`), + stmt(`CREATE INDEX IF NOT EXISTS idx_trashed_tasks_owner ON trashed_tasks(owner_id, trashed_at)`), + stmt(`INSERT OR IGNORE INTO system_settings (key, value) VALUES ('retention_since', ?)`, domain.NowISO()), + ) +} + +func validRetention(days int) bool { + for _, d := range RetentionChoices { + if d == days { + return true + } + } + return false +} + +// RetentionOf returns the user's delays; without allowNever a stored 0 counts as the default. +func (s *Store) RetentionOf(userID string, allowNever bool) Retention { + r := DefaultRetention + _ = s.db.QueryRow(`SELECT done_retention_days, task_trash_days, triage_trash_days, note_trash_days, list_done_days FROM users WHERE id = ?`, userID). + Scan(&r.DoneTasks, &r.TaskTrash, &r.TriageTrash, &r.NoteTrash, &r.ListDone) + if !allowNever { + def := DefaultRetention + for _, f := range retentionFields { + if v := f.field(&r); *v == 0 { + *v = *f.field(&def) + } + } + } + return r +} + +// SetRetention stores all delays; each must be one of RetentionChoices, 0 only when allowed. +func (s *Store) SetRetention(userID string, r Retention, allowNever bool) error { + stmts := []sqlStmt{} + for _, f := range retentionFields { + v := *f.field(&r) + if !validRetention(v) { + return ErrInvalidRetention + } + if v == 0 && !allowNever { + return ErrRetentionNever + } + stmts = append(stmts, stmt(`UPDATE users SET `+f.column+` = ? WHERE id = ?`, v, userID)) + } + return s.execInTx(stmts...) +} + +// retentionSince: when retention started; older items count from then on. +func (s *Store) retentionSince() string { + var v string + _ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'retention_since'`).Scan(&v) + return v +} + +// daysSQL: the delay in days of an owner column, with "never" (0) replaced by def when it is not allowed. +func daysSQL(column string) string { + return `COALESCE(NULLIF(` + column + `, 0), ?)` +} + +// neverAs: what a stored 0 means in daysSQL: 0 (keep) when allowed, else the area's default. +func neverAs(allowNever bool, def int) int { + if allowNever { + return 0 + } + return def +} + +// cutoffSQL: true when the timestamp column (counted from retention_since at the earliest) lies more +// than the delay before now. Arguments: since, since, now (in this order). +func cutoffSQL(tsColumn, days string) string { + return `(CASE WHEN ` + tsColumn + ` = '' OR ` + tsColumn + ` < ? THEN ? ELSE ` + tsColumn + ` END) + < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || ` + days + ` || ' days')` +} + +func sqlNow(now time.Time) string { return now.UTC().Format("2006-01-02 15:04:05") } + +// PurgeResult: what PurgeExpired removed, for the realtime messages. +type PurgeResult struct { + TasksToTrash []DeletedTask + TrashedTasks []string // purged from the task trash for good + Discarded int + Notes []string + ListItems []PurgedListItem +} + +// PurgedListItem: a ticked-off list item deleted after its delay. +type PurgedListItem struct { + ID, ListID string +} + +// PurgeExpired runs every delay at now: completed tasks go to the task trash, the trashes and ticked-off +// list items are emptied for good. +func (s *Store) PurgeExpired(now time.Time, allowNever bool) (PurgeResult, error) { + var res PurgeResult + var err error + if res.TasksToTrash, err = s.PurgeExpiredDoneTasks(now, allowNever); err != nil { + return res, err + } + if res.TrashedTasks, err = s.purgeExpiredTaskTrash(now, allowNever); err != nil { + return res, err + } + since := s.retentionSince() + if since == "" { + since = domain.NowISO() + } + d := DefaultRetention + + // Triage trash: discarded thoughts, from the moment they were discarded (triaged_at) + r, err := s.db.Exec(`DELETE FROM thoughts WHERE status = 'discarded' AND id IN ( + SELECT t.id FROM thoughts t JOIN users u ON u.id = t.user_id + WHERE t.status = 'discarded' AND `+daysSQL("u.triage_trash_days")+` > 0 + AND `+cutoffSQL("t.triaged_at", daysSQL("u.triage_trash_days"))+`)`, + neverAs(allowNever, d.TriageTrash), since, since, sqlNow(now), neverAs(allowNever, d.TriageTrash)) + if err != nil { + return res, err + } + n, _ := r.RowsAffected() + res.Discarded = int(n) + + // Notes trash: the note owner's delay, from the deletion + rows, err := s.db.Query(`SELECT d.id FROM documents d JOIN users u ON u.id = d.user_id + WHERE d.is_archived = 1 AND `+daysSQL("u.note_trash_days")+` > 0 + AND `+cutoffSQL("d.archived_at", daysSQL("u.note_trash_days")), + neverAs(allowNever, d.NoteTrash), since, since, sqlNow(now), neverAs(allowNever, d.NoteTrash)) + if err != nil { + return res, err + } + var docIDs []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + docIDs = append(docIDs, id) + } + } + _ = rows.Close() + for _, id := range docIDs { + if err := s.hardDeleteDocument(id); err != nil { + return res, err + } + res.Notes = append(res.Notes, id) + } + + // Ticked-off list items: the list owner's delay, from ticking them off + rows, err = s.db.Query(`SELECT li.id, li.list_id FROM list_items li JOIN lists l ON l.id = li.list_id JOIN users u ON u.id = l.user_id + WHERE li.is_completed = 1 AND `+daysSQL("u.list_done_days")+` > 0 + AND `+cutoffSQL("li.completed_at", daysSQL("u.list_done_days")), + neverAs(allowNever, d.ListDone), since, since, sqlNow(now), neverAs(allowNever, d.ListDone)) + if err != nil { + return res, err + } + for rows.Next() { + var it PurgedListItem + if rows.Scan(&it.ID, &it.ListID) == nil { + res.ListItems = append(res.ListItems, it) + } + } + _ = rows.Close() + for _, it := range res.ListItems { + if _, err := s.db.Exec(`DELETE FROM list_items WHERE id = ?`, it.ID); err != nil { + return res, err + } + } + return res, nil +} + +// RetentionAllowNeverSetting: the admin's switch in the system settings (default: allowed). +func (s *Store) RetentionAllowNeverSetting() bool { + var v string + _ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'retention_allow_never'`).Scan(&v) + return v != "false" +} diff --git a/internal/storage/system.go b/internal/storage/system.go index 8d8292d..b44b3d0 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -176,6 +176,9 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { if n, err := strconv.Atoi(v); err == nil && n > 0 { settings.BackupRetentionDays = n } + case "retention_allow_never": + allow := v != "false" + settings.RetentionAllowNever = &allow } } } @@ -187,6 +190,10 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { } } settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen + if settings.RetentionAllowNever == nil { + allow := true + settings.RetentionAllowNever = &allow + } return settings, nil } @@ -269,5 +276,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { settings.RegistrationDomains, strconv.FormatBool(settings.RegistrationVerifyEmail), ) + if err == nil && settings.RetentionAllowNever != nil { + _, err = s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('retention_allow_never', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.FormatBool(*settings.RetentionAllowNever)) + } return err } diff --git a/internal/storage/task_trash.go b/internal/storage/task_trash.go new file mode 100644 index 0000000..fffc81e --- /dev/null +++ b/internal/storage/task_trash.go @@ -0,0 +1,267 @@ +package storage + +import ( + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// Deleted tasks go to a trash first: deleting a task, "Erledigte leeren" and the automatic clean-up of +// completed tasks. The task leaves the thoughts table with its whole row and its assignees kept as a +// copy (trashed_tasks), so no query on tasks has to know about the trash; restoring puts the row back +// as it was. Everyone who sees a task list sees its trashed tasks; restoring and deleting for good need +// edit rights, like changing the list. The delay of the list owner (or of the user for personal tasks) +// empties the trash. + +var ErrNotInTaskTrash = errors.New("Aufgabe nicht im Papierkorb") + +// TrashedTask is one entry of the task trash. +type TrashedTask struct { + ID string `json:"id"` + Content string `json:"content"` + TaskListID string `json:"taskListId"` + IsCompleted bool `json:"isCompleted"` + TrashedAt string `json:"trashedAt"` + TrashedBy string `json:"trashedBy"` // display name + CanEdit bool `json:"canEdit"` +} + +// trashTasks moves tasks into the trash, each with its row and assignees, in one transaction per task. +func (s *Store) trashTasks(ids []string, byUserID string) error { + for _, id := range ids { + if err := s.trashTask(id, byUserID); err != nil { + return err + } + } + return nil +} + +func (s *Store) trashTask(id, byUserID string) error { + rows, err := s.db.Query(`SELECT * FROM thoughts WHERE id = ?`, id) + if err != nil { + return err + } + cols, _ := rows.Columns() + data := map[string]any{} + found := false + if rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + _ = rows.Close() + return err + } + for i, c := range cols { + if b, ok := vals[i].([]byte); ok { + data[c] = string(b) + } else { + data[c] = vals[i] + } + } + found = true + } + _ = rows.Close() + if !found { + return nil + } + str := func(k string) string { v, _ := data[k].(string); return v } + userID, listID := str("user_id"), str("task_list_id") + owner := userID + if listID != "" { + _ = s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&owner) + } + done := false + switch v := data["is_completed"].(type) { + case int64: + done = v == 1 + } + assignees := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ?`, id) + dataJSON, _ := json.Marshal(data) + assJSON, _ := json.Marshal(assignees) + return s.execInTx( + stmt(`INSERT OR REPLACE INTO trashed_tasks (id, user_id, task_list_id, owner_id, content, is_completed, data, assignees, trashed_at, trashed_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, userID, listID, owner, str("content"), boolInt(done), string(dataJSON), string(assJSON), domain.NowISO(), byUserID), + stmt(`DELETE FROM thoughts WHERE id = ?`, id), + ) +} + +// taskTrashScope: entries the user can see: own personal tasks and tasks of lists they can see. +const taskTrashScope = `((tt.user_id = ? AND tt.task_list_id = '') OR tt.task_list_id IN (` + accessibleTaskListsSQL + `))` + +func taskTrashArgs(userID string) []any { + return append([]any{userID}, accessibleTaskListArgs(userID)...) +} + +// GetTaskTrash lists the trashed tasks of one task list ("" = all the user can see), newest first. +func (s *Store) GetTaskTrash(userID, taskListID string) ([]TrashedTask, error) { + q := `SELECT tt.id, tt.content, tt.task_list_id, tt.is_completed, tt.trashed_at, COALESCE(u.display_name, '') + FROM trashed_tasks tt LEFT JOIN users u ON u.id = tt.trashed_by + WHERE ` + taskTrashScope + args := taskTrashArgs(userID) + if taskListID != "" { + q += ` AND tt.task_list_id = ?` + args = append(args, taskListID) + } + rows, err := s.db.Query(q+` ORDER BY tt.trashed_at DESC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []TrashedTask{} + for rows.Next() { + var t TrashedTask + var done int + if rows.Scan(&t.ID, &t.Content, &t.TaskListID, &done, &t.TrashedAt, &t.TrashedBy) == nil { + t.IsCompleted = done == 1 + out = append(out, t) + } + } + for i := range out { + out[i].CanEdit = out[i].TaskListID == "" || s.CanEditTaskList(userID, out[i].TaskListID) + } + return out, nil +} + +// trashedTaskFor loads an entry the user may restore or delete. +func (s *Store) trashedTaskFor(userID, id string) (data map[string]any, assignees []string, listID string, err error) { + var raw, rawAss, owner string + err = s.db.QueryRow(`SELECT tt.data, tt.assignees, tt.task_list_id, tt.user_id FROM trashed_tasks tt WHERE tt.id = ? AND `+taskTrashScope, + append([]any{id}, taskTrashArgs(userID)...)...).Scan(&raw, &rawAss, &listID, &owner) + if err == sql.ErrNoRows { + return nil, nil, "", ErrNotInTaskTrash + } + if err != nil { + return nil, nil, "", err + } + if listID != "" && !s.CanEditTaskList(userID, listID) { + return nil, nil, "", ErrTaskListReadOnly + } + _ = json.Unmarshal([]byte(raw), &data) + _ = json.Unmarshal([]byte(rawAss), &assignees) + for k, v := range data { + if f, ok := v.(float64); ok && f == float64(int64(f)) { + data[k] = int64(f) // JSON numbers come back as floats; the columns hold integers + } + } + data["task_list_id"] = listID // a deleted list made it a personal task + return data, assignees, listID, nil +} + +// RestoreTrashedTask puts a task back where it was (a list deleted meanwhile: into its creator's own +// tasks, see DeleteTaskList). A +// completed task starts its delay anew, so the automatic clean-up does not take it again at once. +func (s *Store) RestoreTrashedTask(userID, id string) (*domain.Thought, error) { + data, assignees, _, err := s.trashedTaskFor(userID, id) + if err != nil { + return nil, err + } + cols := map[string]bool{} + rows, err := s.db.Query(`SELECT name FROM pragma_table_info('thoughts')`) + if err != nil { + return nil, err + } + for rows.Next() { + var c string + if rows.Scan(&c) == nil { + cols[c] = true + } + } + _ = rows.Close() + if done, ok := data["is_completed"].(int64); ok && done == 1 { + data["completed_at"] = domain.NowISO() + } + names, marks, args := "", "", []any{} + for k, v := range data { + if !cols[k] || v == nil { + continue + } + if names != "" { + names += ", " + marks += ", " + } + names += k + marks += "?" + args = append(args, v) + } + stmts := []sqlStmt{ + stmt(`INSERT INTO thoughts (`+names+`) VALUES (`+marks+`)`, args...), + stmt(`DELETE FROM trashed_tasks WHERE id = ?`, id), + } + for _, uid := range assignees { + stmts = append(stmts, stmt(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) + SELECT ?, id, ? FROM users WHERE id = ?`, id, domain.NowISO(), uid)) + } + if err := s.execInTx(stmts...); err != nil { + return nil, err + } + s.PruneTaskAssignees() + return s.GetThoughtByIDUnchecked(id) +} + +// PurgeTrashedTask deletes one trashed task for good. +func (s *Store) PurgeTrashedTask(userID, id string) error { + if _, _, _, err := s.trashedTaskFor(userID, id); err != nil { + return err + } + return s.purgeTrashedTasks([]string{id}) +} + +// EmptyTaskTrash deletes the trashed tasks of one list ("" = all the user may edit) for good. +func (s *Store) EmptyTaskTrash(userID, taskListID string) ([]string, error) { + if taskListID != "" { + if err := s.ensureTaskListWritable(userID, taskListID); err != nil { + return nil, err + } + } + items, err := s.GetTaskTrash(userID, taskListID) + if err != nil { + return nil, err + } + ids := []string{} + for _, it := range items { + if it.CanEdit { + ids = append(ids, it.ID) + } + } + return ids, s.purgeTrashedTasks(ids) +} + +func (s *Store) purgeTrashedTasks(ids []string) error { + for _, id := range ids { + if err := s.execInTx( + stmt(`DELETE FROM trashed_tasks WHERE id = ?`, id), + stmt(`DELETE FROM task_watchers WHERE task_id = ?`, id), + ); err != nil { + return err + } + } + return nil +} + +// purgeExpiredTaskTrash empties the task trash after the owner's delay (list owner, else the creator). +func (s *Store) purgeExpiredTaskTrash(now time.Time, allowNever bool) ([]string, error) { + def := neverAs(allowNever, DefaultRetention.TaskTrash) + rows, err := s.db.Query(`SELECT tt.id FROM trashed_tasks tt JOIN users u ON u.id = tt.owner_id + WHERE `+daysSQL("u.task_trash_days")+` > 0 + AND tt.trashed_at < strftime('%Y-%m-%dT%H:%M:%SZ', ?, '-' || `+daysSQL("u.task_trash_days")+` || ' days')`, + def, sqlNow(now), def) + if err != nil { + return nil, err + } + var ids []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + _ = rows.Close() + return ids, s.purgeTrashedTasks(ids) +} diff --git a/internal/storage/task_trash_test.go b/internal/storage/task_trash_test.go new file mode 100644 index 0000000..290659f --- /dev/null +++ b/internal/storage/task_trash_test.go @@ -0,0 +1,217 @@ +package storage + +import ( + "path/filepath" + "testing" + "time" +) + +func inTaskTrash(s *Store, id string) bool { + var n int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM trashed_tasks WHERE id = ?`, id).Scan(&n) + return n == 1 +} + +// Deleting a task, clearing completed ones and the automatic clean-up all go through the trash; +// restoring brings the task back with its fields and assignees. +func TestTaskTrashRoundTrip(t *testing.T) { + s, err := NewStore(filepath.Join(t.TempDir(), "trash.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + openSharing(t, s) + anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member") + carla, _ := s.CreateUser("carla@example.com", "hash", "Carla", "member") + list, _ := s.CreateTaskList(anna.ID, "", "", "Haushalt", "", "") + _, _ = s.AddTaskListShare(list.ID, "user", ben.ID, "editor", anna.ID) + _, _ = s.AddTaskListShare(list.ID, "user", carla.ID, "viewer", anna.ID) + + task, _ := s.CreateThoughtWithDetails(anna.ID, "Fenster putzen #haus", "high", "manual", "2026-11-20", list.ID, "in_progress", "Innen und außen") + if _, err := s.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-20", list.ID); err != nil { + t.Fatal(err) + } + if _, err := s.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID}); err != nil { + t.Fatal(err) + } + before, _ := s.GetThoughtByIDUnchecked(task.ID) + if ass := s.getTaskAssignees(task.ID); len(ass) != 1 { + t.Fatalf("setup: assignee missing: %v", ass) + } + + if err := s.DeleteThought(ben.ID, task.ID); err != nil { + t.Fatal(err) + } + if exists(s, task.ID) || !inTaskTrash(s, task.ID) { + t.Fatal("a deleted task should leave the list and wait in the trash") + } + // Everyone who sees the list sees the entry, only editors may act on it + if items, _ := s.GetTaskTrash(carla.ID, list.ID); len(items) != 1 || items[0].CanEdit || items[0].TrashedBy != "Ben" { + t.Fatalf("viewer should see the entry read-only: %+v", items) + } + if _, err := s.RestoreTrashedTask(carla.ID, task.ID); err == nil { + t.Fatal("a viewer must not restore") + } + outsider, _ := s.CreateUser("dora@example.com", "hash", "Dora", "member") + if items, _ := s.GetTaskTrash(outsider.ID, ""); len(items) != 0 { + t.Fatalf("outsiders see nothing: %+v", items) + } + + restored, err := s.RestoreTrashedTask(anna.ID, task.ID) + if err != nil { + t.Fatal(err) + } + if inTaskTrash(s, task.ID) || restored.Content != before.Content || restored.Notes != before.Notes || restored.Priority != before.Priority || + restored.DueDate != before.DueDate || restored.TaskListID != list.ID || restored.KanbanStatus != before.KanbanStatus { + t.Fatalf("restored task differs:\nbefore %+v\nafter %+v", before, restored) + } + + if ass := s.getTaskAssignees(task.ID); len(ass) != 1 || ass[0] != ben.ID { + t.Fatalf("assignees should come back: %v", ass) + } + + // Deleting for good + _ = s.DeleteThought(anna.ID, task.ID) + if err := s.PurgeTrashedTask(carla.ID, task.ID); err == nil { + t.Fatal("a viewer must not delete for good") + } + if err := s.PurgeTrashedTask(anna.ID, task.ID); err != nil || inTaskTrash(s, task.ID) { + t.Fatalf("purge failed: %v", err) + } +} + +// A restored completed task starts its delay anew; the clean-up then waits again. +func TestRestoredDoneTaskIsNotTrashedAgainAtOnce(t *testing.T) { + s, err := NewStore(filepath.Join(t.TempDir(), "again.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member") + id := doneTask(t, s, anna.ID, "", "alt", "2020-01-01T08:00:00Z") + if moved, _ := s.PurgeExpiredDoneTasks(time.Now(), true); len(moved) != 1 || !inTaskTrash(s, id) { + t.Fatalf("old completed task should be in the trash: %+v", moved) + } + if _, err := s.RestoreTrashedTask(anna.ID, id); err != nil { + t.Fatal(err) + } + if moved, _ := s.PurgeExpiredDoneTasks(time.Now(), true); len(moved) != 0 || !exists(s, id) { + t.Fatal("restored task must not go straight back to the trash") + } +} + +// Deleting a task list turns its trashed tasks into their creators' own, like its tasks. +func TestTaskTrashAfterListDeletion(t *testing.T) { + s, err := NewStore(filepath.Join(t.TempDir(), "listgone.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + openSharing(t, s) + anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member") + list, _ := s.CreateTaskList(anna.ID, "", "", "Projekt", "", "") + _, _ = s.AddTaskListShare(list.ID, "user", ben.ID, "editor", anna.ID) + task, _ := s.CreateThoughtWithDetails(ben.ID, "Bens Aufgabe", "normal", "manual", "", list.ID, "todo", "") + _, _ = s.UpdateThoughtTriage(ben.ID, task.ID, "keep", "task", "", "", list.ID) + _ = s.DeleteThought(ben.ID, task.ID) + if err := s.DeleteTaskList(anna.ID, list.ID); err != nil { + t.Fatal(err) + } + if items, _ := s.GetTaskTrash(ben.ID, ""); len(items) != 1 || items[0].TaskListID != "" { + t.Fatalf("Ben should find his task in his own trash: %+v", items) + } + restored, err := s.RestoreTrashedTask(ben.ID, task.ID) + if err != nil || restored.TaskListID != "" || restored.UserID != ben.ID { + t.Fatalf("should come back as Ben's own task: %+v %v", restored, err) + } +} + +// Every area keeps its delay; older items count from the update on; "never" can be switched off. +func TestPurgeExpiredAreas(t *testing.T) { + s, err := NewStore(filepath.Join(t.TempDir(), "areas.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member") + if r := s.RetentionOf(anna.ID, true); r != DefaultRetention { + t.Fatalf("defaults: %+v", r) + } + if err := s.SetRetention(ben.ID, Retention{}, false); err != ErrRetentionNever { + t.Fatalf("never must be refused when switched off: %v", err) + } + if err := s.SetRetention(ben.ID, Retention{DoneTasks: 12}, true); err != ErrInvalidRetention { + t.Fatalf("only offered delays: %v", err) + } + if err := s.SetRetention(ben.ID, Retention{}, true); err != nil { // Ben keeps everything + t.Fatal(err) + } + if r := s.RetentionOf(ben.ID, false); r != DefaultRetention { + t.Fatalf("with never switched off Ben gets the defaults: %+v", r) + } + + _, _ = s.db.Exec(`UPDATE system_settings SET value = '2026-01-01T00:00:00Z' WHERE key = 'retention_since'`) + now := time.Date(2026, 12, 15, 12, 0, 0, 0, time.UTC) + + // Triage trash (14 days), timestamps before the update count from the update + discard := func(user string, at string) string { + th, _ := s.CreateThought(user, "weg", "normal", "manual", "") + _, _ = s.UpdateThoughtTriage(user, th.ID, "discard", "", "", "", "") + _, _ = s.db.Exec(`UPDATE thoughts SET triaged_at = ? WHERE id = ?`, at, th.ID) + return th.ID + } + oldDiscard := discard(anna.ID, "2026-11-01T00:00:00Z") + newDiscard := discard(anna.ID, "2026-12-10T00:00:00Z") + benDiscard := discard(ben.ID, "2026-01-02T00:00:00Z") + + // Notes trash (30 days) + doc, _ := s.CreateDocument(anna.ID, "", "Alte Notiz", "", "", nil, nil, false) + _ = s.DeleteDocument(anna.ID, doc.ID) + _, _ = s.db.Exec(`UPDATE documents SET archived_at = '2026-10-01T00:00:00Z' WHERE id = ?`, doc.ID) + + // Ticked-off list items (7 days, list owner) + lst, _ := s.CreateList(anna.ID, "Einkauf", "🛒", "shopping") + oldItem, _ := s.CreateListItem(lst.ID, "Milch", "", "", 0, anna.ID) + freshItem, _ := s.CreateListItem(lst.ID, "Brot", "", "", 0, anna.ID) + openItem, _ := s.CreateListItem(lst.ID, "Käse", "", "", 0, anna.ID) + _, _ = s.ToggleListItem(oldItem.ID, true, anna.ID, "Anna") + _, _ = s.ToggleListItem(freshItem.ID, true, anna.ID, "Anna") + _, _ = s.db.Exec(`UPDATE list_items SET completed_at = '2026-12-01T00:00:00Z' WHERE id = ?`, oldItem.ID) + _, _ = s.db.Exec(`UPDATE list_items SET completed_at = '2026-12-14T00:00:00Z' WHERE id = ?`, freshItem.ID) + + res, err := s.PurgeExpired(now, true) + if err != nil { + t.Fatal(err) + } + if exists(s, oldDiscard) || !exists(s, newDiscard) || !exists(s, benDiscard) || res.Discarded != 1 { + t.Fatalf("triage trash wrong: %+v", res) + } + var docs int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, doc.ID).Scan(&docs) + if docs != 0 || len(res.Notes) != 1 { + t.Fatalf("old note should be gone: %+v", res) + } + itemExists := func(id string) bool { + var n int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM list_items WHERE id = ?`, id).Scan(&n) + return n == 1 + } + if itemExists(oldItem.ID) || !itemExists(freshItem.ID) || !itemExists(openItem.ID) || len(res.ListItems) != 1 { + t.Fatalf("list items wrong: %+v", res.ListItems) + } + + // With "never" switched off, Ben's discarded thought (older than 14 days after the update) goes too + if res, _ := s.PurgeExpired(now, false); exists(s, benDiscard) || res.Discarded != 1 { + t.Fatalf("Ben's never should count as 14 days: %+v", res) + } + + // Items from before the update count from the update: with a fresh update nothing old goes + _, _ = s.db.Exec(`UPDATE system_settings SET value = '2026-12-14T00:00:00Z' WHERE key = 'retention_since'`) + veryOld := discard(anna.ID, "2025-01-01T00:00:00Z") + if _, _ = s.PurgeExpired(now, true); !exists(s, veryOld) { + t.Fatal("things from before the update count from the update on") + } +} diff --git a/internal/storage/tasklists.go b/internal/storage/tasklists.go index 48b846a..73b046e 100644 --- a/internal/storage/tasklists.go +++ b/internal/storage/tasklists.go @@ -549,6 +549,8 @@ func (s *Store) DeleteTaskList(userID, listID string) error { // The tasks stay (as personal tasks of their owners), shares and the list go – all or nothing if err := s.execInTx( stmt(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID), + // Its trashed tasks likewise land in their creators' own trash + stmt(`UPDATE trashed_tasks SET task_list_id = '', owner_id = user_id WHERE task_list_id = ?`, listID), stmt(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID), stmt(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID), ); err != nil { diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index 9342103..b7ed6c5 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -523,6 +523,8 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, return thought, nil } +// DeleteThought: a task goes to the task trash (task_trash.go); anything else (a thought from the inbox +// or the triage trash) is deleted for good. func (s *Store) DeleteThought(userID, thoughtID string) error { thought, err := s.GetThoughtByID(userID, thoughtID) if err != nil { @@ -531,6 +533,10 @@ func (s *Store) DeleteThought(userID, thoughtID string) error { if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil { return err } + // Tasks as in completedTaskSQL: kept, or converted into a task + if thought.Status == domain.StatusKept || (thought.Status == domain.StatusConverted && thought.ConvertedType == "task") { + return s.trashTask(thought.ID, userID) + } _, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID) return err }