feat: task trash, triage trash and retention settings for every area; admin can forbid keeping things for good
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a927a94a95
commit
5a9c5bac37
36 files changed
+1356
-175
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user