feat(tasks): completed tasks are deleted after a delay (default 30 days), "Erledigte leeren"
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m34s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m34s
thoughts.completed_at is set by triggers on every way a task is completed (migration 14; tasks completed earlier count from the update). Users choose the delay (7/30/90/365 days or never, users.done_retention_days); for tasks in a list the owner's delay counts. An hourly job deletes expired completed tasks (not in the demo) and tells their viewers. POST /tasks/clear-done clears completed tasks the user may edit. The done tab shows the setting and the button. v0.35.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
66b28e1998
commit
f91926c208
16 files changed
+433
-6
No files matched your search
+7
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](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.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -690,6 +690,31 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onRefreshStats();
|
||||
};
|
||||
|
||||
// Completed tasks: cleared by hand or deleted after the owner's delay (done_cleanup.go)
|
||||
const [doneRetentionDays, setDoneRetentionDays] = useState<number>(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<TasksViewProps> = ({
|
||||
Papierkorb leeren
|
||||
</button>
|
||||
)}
|
||||
{filter === 'done' && doneTasks.length > 0 && !isSelectedListReadOnly && (
|
||||
<button onClick={handleClearDone} className="btn-action min-h-[38px] sm:min-h-0 ml-auto">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Erledigte leeren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* What happens to completed tasks; the delay of the list owner counts */}
|
||||
{filter === 'done' && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted px-1">
|
||||
{currentSelectedList && currentSelectedList.isOwner === false ? (
|
||||
<span>
|
||||
Erledigte Aufgaben dieser Liste räumt die Einstellung von {currentSelectedList.ownerName || 'der Eigentümerin bzw. dem Eigentümer'} auf.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span>Erledigte Aufgaben löschen</span>
|
||||
<select
|
||||
value={doneRetentionDays}
|
||||
onChange={(e) => handleChangeDoneRetention(Number(e.target.value))}
|
||||
className="h-9 sm:h-6 px-1.5 input-field text-xs bg-panel"
|
||||
aria-label="Erledigte Aufgaben löschen nach"
|
||||
>
|
||||
<option value={7}>nach 7 Tagen</option>
|
||||
<option value={30}>nach 30 Tagen</option>
|
||||
<option value={90}>nach 90 Tagen</option>
|
||||
<option value={365}>nach einem Jahr</option>
|
||||
<option value={0}>nie</option>
|
||||
</select>
|
||||
<span className="text-faint">
|
||||
{doneRetentionDays > 0 ? '(gezählt ab dem Abhaken; gilt für deine eigenen Listen)' : '(bleiben, bis du sie leerst)'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="p-6 text-center text-faint text-sm">Lade Aufgaben…</div>
|
||||
) : filter === 'discarded' ? (
|
||||
|
||||
@@ -300,6 +300,7 @@ export const api = {
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
notificationSettings?: NotificationSettings;
|
||||
doneRetentionDays?: number;
|
||||
}): Promise<User> {
|
||||
return request<User>('/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' });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user