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 }