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
|
||||
|
||||
Reference in new issue
Block a user