MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
8.5 KiB
Go
240 lines
8.5 KiB
Go
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"
|
|
}
|