diff --git a/CHANGELOG.md b/CHANGELOG.md index 9421d22..c50fb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.35.0] - 2026-11-01 + +### Added +- Erledigte Aufgaben räumen sich auf: Standardmäßig werden sie 30 Tage nach dem Abhaken gelöscht. Im Reiter „Erledigt“ stellst du die Frist ein (7, 30, 90 Tage, ein Jahr oder nie); in geteilten Listen gilt die Einstellung des Eigentümers. Neu ist dort auch „Erledigte leeren“ zum sofortigen Löschen. Aufgaben, die vor diesem Update erledigt wurden, bekommen ihre volle Frist ab dem Update, es verschwindet also nichts sofort. + ## [0.34.0] - 2026-11-01 ### Changed @@ -601,7 +606,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.34.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...dev +[0.35.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.34.0...v0.35.0 [0.34.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.33.0...v0.34.0 [0.33.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.32.0...v0.33.0 [0.32.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.31.0...v0.32.0 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index cbe44e2..eadb3bd 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`. `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`, `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` | | 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) | @@ -136,6 +136,7 @@ Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnte | 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/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/modules/tasks.md b/Doku/modules/tasks.md index 511a21d..c435c9a 100644 --- a/Doku/modules/tasks.md +++ b/Doku/modules/tasks.md @@ -10,6 +10,7 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus - **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). Farben in „Spalten anpassen“ über den Punkt vor dem Namen: Grau, Anthrazit (Textfarbe des Themes), Grün, Gelb, Blau, Rot, Lila, Akzentfarbe (`columns[].color` = `gray`/`slate`/`green`/`yellow`/`blue`/`red`/`purple`/`accent`; ältere Werte `emerald`/`amber` gelten als Grün/Gelb, Spalten ohne Farbe behalten die ihrer Rolle). Der Punkt erscheint im Spaltenkopf, vor dem Status in der Listenansicht und im Detail-Fenster ([ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx), `columnDotClass`). Blau und Lila kommen aus `--color-info` / `--color-purple`; Themes können sie setzen, sonst gelten Standardwerte aus `index.css`. Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`. +- **Erledigte aufräumen** ([done_cleanup.go](../../internal/storage/done_cleanup.go)): Erledigte Aufgaben werden standardmäßig 30 Tage nach dem Abhaken gelöscht. Die Frist stellt jede Person im Reiter „Erledigt“ ein (7, 30, 90, 365 Tage oder nie, `users.done_retention_days`); für Aufgaben in einer Liste gilt die Frist des Eigentümers, für persönliche die eigene. Den Zeitpunkt setzt ein Trigger (`thoughts.completed_at`), egal auf welchem Weg abgehakt wird; der Server räumt stündlich auf (nicht in der Demo). „Erledigte leeren“ (`POST /tasks/clear-done`) löscht sofort, aber nur, was man bearbeiten darf. Ein mit einer Notiz verknüpftes Häkchen bleibt dabei als normales Häkchen stehen. - Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen). - „Als Notiz vertiefen“ legt aus einer Aufgabe eine Notiz an (Titel, Notizen, Unteraufgaben, Fälligkeit, Priorität als Inhalt). Die Aufgabe bleibt in ihrer Liste und wird mit dem ersten Häkchen der Notiz verknüpft (`converted_type = document_block`); ein Gedanke aus der Triage wird dagegen ganz zur Notiz. Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block. diff --git a/README.md b/README.md index 809ec74..63ebbad 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.34.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.35.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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 00cae2d..6853103 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.34.0", + "version": "0.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.34.0", + "version": "0.35.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 256c0fc..6a401a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.34.0", + "version": "0.35.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 6f78071..5c8f5ea 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -690,6 +690,31 @@ export const TasksView: React.FC = ({ onRefreshStats(); }; + // Completed tasks: cleared by hand or deleted after the owner's delay (done_cleanup.go) + const [doneRetentionDays, setDoneRetentionDays] = useState(currentUser?.doneRetentionDays ?? 30); + useEffect(() => { + if (currentUser?.doneRetentionDays !== undefined) setDoneRetentionDays(currentUser.doneRetentionDays); + }, [currentUser?.doneRetentionDays]); + + const handleChangeDoneRetention = async (days: number) => { + const previous = doneRetentionDays; + setDoneRetentionDays(days); + try { + await api.updateSettings({ doneRetentionDays: days }); + } catch { + setDoneRetentionDays(previous); + } + }; + + const handleClearDone = async () => { + const scope = selectedListId === 'all' ? 'aller Listen, die du bearbeiten darfst,' : `aus „${currentSelectedList?.title || 'dieser Liste'}“`; + if (!window.confirm(`Alle erledigten Aufgaben ${scope} endgültig löschen?`)) return; + triggerHaptic(20); + await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId); + await loadData(); + onRefreshStats(); + }; + const handlePurgeAllDiscarded = async () => { if (!window.confirm('Alle verworfenen Gedanken endgültig löschen?')) return; triggerHaptic(20); @@ -2586,8 +2611,44 @@ export const TasksView: React.FC = ({ Papierkorb leeren )} + {filter === 'done' && doneTasks.length > 0 && !isSelectedListReadOnly && ( + + )} + {/* 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' ? ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5b3675f..b60be80 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -300,6 +300,7 @@ export const api = { customWebhookUrl?: string; notifyBeforeMinutes?: number; notificationSettings?: NotificationSettings; + doneRetentionDays?: number; }): Promise { return request('/api/v1/auth/settings', { method: 'PATCH', @@ -472,6 +473,11 @@ export const api = { await request(`/api/v1/thoughts/${thoughtId}`, { method: 'DELETE' }); }, + // "Erledigte leeren": completed tasks of one task list ('' = all the user may edit) + async clearDoneTasks(taskListId: string): Promise<{ deleted: number }> { + return request<{ deleted: number }>('/api/v1/tasks/clear-done', { 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/types.ts b/frontend/src/lib/types.ts index cc35f21..3225778 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -111,6 +111,8 @@ export interface User { customWebhookUrl?: string; notifyBeforeMinutes?: number; notificationSettings?: NotificationSettings; + // Completed tasks are deleted this many days after ticking them off (0 = never) + doneRetentionDays?: number; organizationId?: string; createdAt: string; } diff --git a/internal/api/done_cleanup.go b/internal/api/done_cleanup.go new file mode 100644 index 0000000..c659699 --- /dev/null +++ b/internal/api/done_cleanup.go @@ -0,0 +1,59 @@ +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" + + "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. +func (s *Server) startDoneCleanupTicker() { + time.Sleep(2 * time.Minute) + s.runDoneCleanup(time.Now()) + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for now := range ticker.C { + s.runDoneCleanup(now) + } +} + +func (s *Server) runDoneCleanup(now time.Time) { + deleted, err := s.store.PurgeExpiredDoneTasks(now) + if err != nil { + slog.Error("Aufräumen erledigter Aufgaben fehlgeschlagen", "error", err) + return + } + if len(deleted) > 0 { + slog.Info("Erledigte Aufgaben aufgeräumt", "count", len(deleted)) + } + s.broadcastDeletedTasks(deleted) +} + +// broadcastDeletedTasks tells everyone who saw the tasks; there are no watcher notifications for this. +func (s *Server) broadcastDeletedTasks(deleted []storage.DeletedTask) { + for _, d := range deleted { + audience := append(s.store.GetTaskListUserIDs(d.TaskListID), d.UserID) + s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": d.ID}) + } +} + +// handleClearDoneTasks: "Erledigte leeren" 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 { + TaskListID string `json:"taskListId"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + deleted, err := s.store.ClearDoneTasks(userID, req.TaskListID) + if err != nil { + writeStoreError(w, http.StatusBadRequest, err) + return + } + s.broadcastDeletedTasks(deleted) + writeJSON(w, http.StatusOK, map[string]int{"deleted": len(deleted)}) +} diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 39d5bb3..bfc97c7 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -403,6 +403,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) writeJSON(w, http.StatusOK, map[string]interface{}{ "user": user, "stats": stats, @@ -512,6 +513,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request CurrentPassword string `json:"currentPassword"` // Which notifications go where (notify.go) NotificationSettings *domain.NotificationSettings `json:"notificationSettings"` + // Days after which completed tasks are deleted (done_cleanup.go) + DoneRetentionDays *int `json:"doneRetentionDays"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -598,8 +601,15 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request return } } + if req.DoneRetentionDays != nil { + if err := s.store.SetDoneRetentionDays(userID, *req.DoneRetentionDays); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } ns := s.store.NotificationSettingsOf(userID) u.NotificationSettings = &ns + u.DoneRetentionDays = s.store.DoneRetentionDays(userID) writeJSON(w, http.StatusOK, u) } diff --git a/internal/api/server.go b/internal/api/server.go index a323a75..2345220 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -71,6 +71,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS go srv.startAlarmTicker() // the demo sends nothing (FLAW-33) go srv.startNotificationJobs() go srv.startBackupScheduler() + go srv.startDoneCleanupTicker() } return srv } @@ -190,6 +191,7 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/tasks", s.handleGetTasks) 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.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks) // Task Lists & Shares diff --git a/internal/domain/models.go b/internal/domain/models.go index d017857..79f5af6 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -141,6 +141,8 @@ type User struct { EmailUnverified bool `json:"emailUnverified,omitempty"` // Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings) NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"` + // DoneRetentionDays: completed tasks are deleted this many days after ticking them off (0 = never) + DoneRetentionDays int `json:"doneRetentionDays"` } // NotificationSettings: which events reach a user over which channel, the daily summary and a pause. diff --git a/internal/storage/done_cleanup.go b/internal/storage/done_cleanup.go new file mode 100644 index 0000000..57a9e54 --- /dev/null +++ b/internal/storage/done_cleanup.go @@ -0,0 +1,148 @@ +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} + +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 + } + if err := s.addColumnIfMissing("thoughts", "completed_at", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + return s.execInTx( + // Tasks completed before this update count from now on, so nothing disappears at once + stmt(`UPDATE thoughts SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE is_completed = 1 AND completed_at = ''`), + stmt(`CREATE TRIGGER IF NOT EXISTS trg_thoughts_completed AFTER UPDATE OF is_completed ON thoughts + WHEN NEW.is_completed != OLD.is_completed BEGIN + UPDATE thoughts SET completed_at = CASE WHEN NEW.is_completed = 1 THEN strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ELSE '' END + WHERE id = NEW.id; + END`), + stmt(`CREATE TRIGGER IF NOT EXISTS trg_thoughts_completed_insert AFTER INSERT ON thoughts + WHEN NEW.is_completed = 1 AND NEW.completed_at = '' BEGIN + UPDATE thoughts SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = NEW.id; + END`), + ) +} + +// DoneRetentionDays: after how many days the user's completed tasks are deleted (0 = never). +func (s *Store) DoneRetentionDays(userID string) int { + days := DefaultDoneRetentionDays + _ = s.db.QueryRow(`SELECT done_retention_days FROM users WHERE id = ?`, userID).Scan(&days) + return days +} + +// SetDoneRetentionDays stores one of DoneRetentionChoices; 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 { + 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. +type DeletedTask struct { + ID, UserID, TaskListID string +} + +// 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) { + 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")) + if err != nil { + return nil, err + } + return s.deleteTasks(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. +func (s *Store) ClearDoneTasks(userID, taskListID string) ([]DeletedTask, error) { + if taskListID != "" { + if err := s.ensureTaskListWritable(userID, taskListID); err != nil { + return nil, err + } + rows, err := s.db.Query(`SELECT t.id, t.user_id, t.task_list_id FROM thoughts t + WHERE `+completedTaskSQL+` AND t.task_list_id = ?`, taskListID) + if err != nil { + return nil, err + } + return s.deleteTasks(rows) + } + lists, err := s.GetTaskLists(userID) + if err != nil { + return nil, err + } + writable := []any{userID} + marks := []string{} + for _, l := range lists { + if l.Permission != "viewer" { + writable = append(writable, l.ID) + marks = append(marks, "?") + } + } + inLists := "" + if len(marks) > 0 { + inLists = ` OR t.task_list_id IN (` + strings.Join(marks, ",") + `)` + } + rows, err := s.db.Query(`SELECT t.id, t.user_id, t.task_list_id FROM thoughts t + WHERE `+completedTaskSQL+` AND ((t.user_id = ? AND t.task_list_id = '')`+inLists+`)`, writable...) + if err != nil { + return nil, err + } + return s.deleteTasks(rows) +} + +// deleteTasks deletes the tasks the query rows name and returns them. +func (s *Store) deleteTasks(rows interface { + Next() bool + Scan(dest ...any) error + Close() error +}) ([]DeletedTask, error) { + var out []DeletedTask + for rows.Next() { + var d DeletedTask + if rows.Scan(&d.ID, &d.UserID, &d.TaskListID) == nil { + out = append(out, d) + } + } + _ = rows.Close() + for _, d := range out { + if _, err := s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, d.ID); err != nil { + return nil, err + } + } + return out, nil +} diff --git a/internal/storage/done_cleanup_test.go b/internal/storage/done_cleanup_test.go new file mode 100644 index 0000000..1cda6d8 --- /dev/null +++ b/internal/storage/done_cleanup_test.go @@ -0,0 +1,128 @@ +package storage + +import ( + "path/filepath" + "testing" + "time" +) + +func doneTask(t *testing.T, s *Store, userID, listID, content, completedAt string) string { + t.Helper() + th, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", listID, "todo", "") + if err != nil { + t.Fatal(err) + } + if _, err := s.UpdateThoughtTriage(userID, th.ID, "keep", "task", "", "", listID); err != nil { + t.Fatal(err) + } + done := true + if _, err := s.UpdateThoughtDetails(userID, th.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + if completedAt != "" { + _, _ = s.db.Exec(`UPDATE thoughts SET completed_at = ? WHERE id = ?`, completedAt, th.ID) + } + return th.ID +} + +func exists(s *Store, id string) bool { + var n int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE id = ?`, id).Scan(&n) + return n == 1 +} + +// Completing a task stamps completed_at, reopening clears it (trigger, whichever path changes the task). +func TestCompletedAtFollowsCompletion(t *testing.T) { + s, err := NewStore(filepath.Join(t.TempDir(), "done.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, "", "Filter wechseln", "") + var at string + _ = s.db.QueryRow(`SELECT completed_at FROM thoughts WHERE id = ?`, id).Scan(&at) + if _, err := time.Parse("2006-01-02T15:04:05Z", at); err != nil { + t.Fatalf("completed_at not set on completion: %q", at) + } + open := false + _, _ = s.UpdateThoughtDetails(anna.ID, id, nil, &open, nil, nil, nil, nil, nil, nil, nil) + _ = s.db.QueryRow(`SELECT completed_at FROM thoughts WHERE id = ?`, id).Scan(&at) + if at != "" { + t.Fatalf("reopening should clear completed_at: %q", at) + } +} + +// The delay of the list owner (or the user for personal tasks) decides; 0 keeps everything. +func TestPurgeExpiredDoneTasks(t *testing.T) { + s, err := NewStore(filepath.Join(t.TempDir(), "purge.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") + if s.DoneRetentionDays(anna.ID) != 30 { + t.Fatalf("default delay should be 30 days") + } + _ = s.SetDoneRetentionDays(ben.ID, 0) // Ben keeps everything + if err := s.SetDoneRetentionDays(ben.ID, 12); err == nil { + t.Fatal("only the offered delays are allowed") + } + shared, _ := s.CreateTaskList(anna.ID, "", "", "Haushalt", "", "") + if _, err := s.AddTaskListShare(shared.ID, "user", ben.ID, "editor", anna.ID); err != nil { + t.Fatal(err) + } + + now := time.Date(2026, 12, 15, 0, 30, 0, 0, time.UTC) + oldAnna := doneTask(t, s, anna.ID, "", "alt", "2026-11-10T08:00:00Z") // 35 days → gone + freshAnna := doneTask(t, s, anna.ID, "", "frisch", "2026-11-20T08:00:00Z") // 25 days → stays + oldBen := doneTask(t, s, ben.ID, "", "Ben alt", "2026-01-01T08:00:00Z") // Ben: never + 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) + if err != nil { + t.Fatal(err) + } + if exists(s, oldAnna) || !exists(s, freshAnna) || !exists(s, oldBen) || exists(s, benInAnnasList) || !exists(s, openTask.ID) { + t.Fatalf("wrong tasks purged: %+v", deleted) + } + if len(deleted) != 2 { + t.Fatalf("expected 2 deleted, got %d", len(deleted)) + } + // Edge: exactly at the limit nothing happens yet + edge := doneTask(t, s, anna.ID, "", "Grenze", "2026-11-15T00:30:00Z") + _, _ = s.PurgeExpiredDoneTasks(now) + if !exists(s, edge) { + t.Fatal("a task completed exactly 30 days ago stays until the limit has passed") + } +} + +// "Erledigte leeren" only deletes what the user may delete. +func TestClearDoneTasks(t *testing.T) { + s, err := NewStore(filepath.Join(t.TempDir(), "clear.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") + readOnly, _ := s.CreateTaskList(anna.ID, "", "", "Nur lesen", "", "") + _, _ = s.AddTaskListShare(readOnly.ID, "user", ben.ID, "viewer", anna.ID) + annaDone := doneTask(t, s, anna.ID, readOnly.ID, "Annas erledigt", "") + benDone := doneTask(t, s, ben.ID, "", "Bens erledigt", "") + + if _, err := s.ClearDoneTasks(ben.ID, readOnly.ID); err == nil { + t.Fatal("a viewer must not clear a list") + } + deleted, err := s.ClearDoneTasks(ben.ID, "") + if err != nil || len(deleted) != 1 || exists(s, benDone) || !exists(s, annaDone) { + t.Fatalf("Ben should only clear his own: %+v %v", deleted, err) + } + if deleted, _ := s.ClearDoneTasks(anna.ID, readOnly.ID); len(deleted) != 1 || exists(s, annaDone) { + t.Fatalf("owner clears her list: %+v", deleted) + } +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 733ee7b..895d70e 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -34,6 +34,7 @@ var migrations = []migration{ {11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification}, {12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark}, {13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin}, + {14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup}, } func (s *Store) migrate() error {