Files
MindSift/internal/storage/thoughts.go
T
sandrodachandClaude Opus 5.5 6b56a33500
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
fix(db): report write errors, multi-step changes all or nothing (FLAW-14)
Store.execInTx runs related statements in one transaction and returns
the first error. Used for deleting and moving folders (with the trash),
shared folders, folder shares and mounts, deleting task lists and
accounts, pinning, and the password reset (new password and the end of
all other links together). Writes whose success the API reports now
pass their error to the client (logout, new password, setup, sharing
mode, new group, restore, unlinking a note block), follow-up tasks and
alarm markers log failures. Remaining ignored writes are listed as
intentional in flaws.md.

Found on the way: DeleteTaskList unlinked all tasks before checking
ownership, so a stranger with the list ID could empty it; viewers could
unlink tasks from note blocks. Both fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:59:23 +01:00

670 lines
21 KiB
Go

package storage
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// --- THOUGHTS & TRIAGE ---
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
return s.CreateThoughtWithDetails(userID, content, priority, source, dueDate, "", "todo", "")
}
func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, kanbanStatus, notes string) (*domain.Thought, error) {
content = strings.TrimSpace(content)
dueDate = domain.NormalizeDue(dueDate)
if content == "" {
return nil, errors.New("content cannot be empty")
}
if priority == "" {
priority = "normal"
}
if source == "" {
source = "manual"
}
if kanbanStatus == "" {
kanbanStatus = "todo"
}
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
tags := domain.ExtractTags(content)
if strings.EqualFold(source, "voice") {
hasDiktat := false
for _, tag := range tags {
if strings.EqualFold(tag, "diktat") {
hasDiktat = true
break
}
}
if !hasDiktat {
tags = append(tags, "diktat")
}
}
if strings.EqualFold(source, "webhook") {
hasWebhook := false
for _, tag := range tags {
if strings.EqualFold(tag, "webhook") {
hasWebhook = true
break
}
}
if !hasWebhook {
tags = append(tags, "webhook")
}
}
tagsJSON, _ := json.Marshal(tags)
t := &domain.Thought{
ID: domain.NewID("th"),
UserID: userID,
Content: content,
Tags: tags,
Priority: priority,
Source: source,
Status: domain.StatusInbox,
DueDate: dueDate,
TaskListID: taskListID,
KanbanStatus: kanbanStatus,
Notes: notes,
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
)
if err != nil {
return nil, err
}
t.Subtasks = []domain.SubTask{}
return t, nil
}
func scanThought(rows interface {
Scan(dest ...any) error
}) (*domain.Thought, error) {
t := &domain.Thought{}
var tagsStr, subtasksStr, statusStr string
var isCompleted, alarmSent, sortOrder int
err := rows.Scan(
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
)
if err != nil {
return nil, err
}
t.SortOrder = sortOrder
t.Status = domain.ThoughtStatus(statusStr)
t.IsCompleted = isCompleted != 0
t.AlarmSent = alarmSent != 0
if t.KanbanStatus == "" {
if t.IsCompleted {
t.KanbanStatus = "done"
} else {
t.KanbanStatus = "todo"
}
}
t.Tags = []string{}
_ = json.Unmarshal([]byte(tagsStr), &t.Tags)
if t.ConvertedType == "document_block" && strings.Contains(t.ConvertedRefID, ":") {
parts := strings.SplitN(t.ConvertedRefID, ":", 2)
t.DocID = parts[0]
t.BlockID = parts[1]
}
t.Assignees = []string{}
t.Subtasks = []domain.SubTask{}
if subtasksStr != "" && subtasksStr != "null" {
_ = json.Unmarshal([]byte(subtasksStr), &t.Subtasks)
}
return t, nil
}
func (s *Store) accessibleTaskListIDsQuery() string {
return `SELECT id FROM task_lists
WHERE user_id = ?
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)`
}
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
row := s.db.QueryRow(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID,
)
t, err := scanThought(row)
if err != nil {
return nil, err
}
t.Assignees = s.getTaskAssignees(t.ID)
return t, nil
}
func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Thought, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
var rows *sql.Rows
var err error
if statusFilter != "" && statusFilter != "all" {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE user_id = ? AND status = ?
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
userID, statusFilter, limit,
)
} else {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts WHERE user_id = ?
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
userID, limit,
)
}
if err != nil {
return nil, err
}
result := make([]domain.Thought, 0)
for rows.Next() {
t, err := scanThought(rows)
if err == nil {
result = append(result, *t)
}
}
_ = rows.Close()
return result, nil
}
func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, error) {
var rows *sql.Rows
var err error
if taskListID != "" {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND task_list_id = ?
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, taskListID,
)
} else {
rows, err = s.db.Query(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence
FROM thoughts
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID,
)
}
if err != nil {
return nil, err
}
result := make([]domain.Thought, 0)
for rows.Next() {
t, err := scanThought(rows)
if err == nil {
result = append(result, *t)
}
}
_ = rows.Close()
s.attachTaskAssignees(result)
return result, nil
}
func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderItem) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var completed []string // tasks that move into "done" now (repeating ones spawn their successor)
for _, it := range items {
if it.KanbanStatus != nil && *it.KanbanStatus != "" {
isCompleted := 0
if *it.KanbanStatus == "done" {
isCompleted = 1
var was int
_ = tx.QueryRow(`SELECT is_completed FROM thoughts WHERE id = ?`, it.ID).Scan(&was)
if was == 0 {
completed = append(completed, it.ID)
}
}
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END,
alarm_failures = CASE WHEN ? = 0 THEN 0 ELSE alarm_failures END WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, isCompleted, it.ID,
)
} else {
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ? WHERE id = ?`,
it.SortOrder, it.ID,
)
}
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
for _, id := range completed {
s.spawnNextOccurrence(id)
}
return nil
}
// UpdateThoughtTriage handles swipe left (discard), swipe right (keep), restore, and conversion to task/document/list_item
func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, targetListID, dueDate, taskListID string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return nil, err
}
now := domain.NowISO()
newStatus := domain.StatusKept
convertedType := thought.ConvertedType
convertedRefID := thought.ConvertedRefID
dueDate = domain.NormalizeDue(dueDate)
if dueDate == "" {
dueDate = thought.DueDate
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID == "" {
taskListID = thought.TaskListID
} else if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
switch action {
case "discard":
newStatus = domain.StatusDiscarded
case "restore":
newStatus = domain.StatusInbox
case "keep":
newStatus = domain.StatusKept
if convertTo == "task" {
newStatus = domain.StatusConverted
convertedType = "task"
} else if convertTo == "document" {
newStatus = domain.StatusConverted
convertedType = "document"
title := thought.Content
if len(title) > 72 {
title = title[:72] + "…"
}
blocks := make([]domain.DocumentBlock, 0)
// 17. Wenn ich eine Aufgabe zu einer Notiz mache, muss die erste Zeile das "To-Do" noch mal als "To-Do" enthalten, auch wenn es schon in der Überschrift steht. Wenn es vorher schon abgehakt war, dann den Zustand natürlich übernehmen.
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "todo",
Content: thought.Content,
Checked: thought.IsCompleted,
Level: 0,
})
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
if strings.TrimSpace(thought.Notes) != "" {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.TrimSpace(thought.Notes),
Level: 1,
})
}
if len(thought.Subtasks) > 0 {
for _, st := range thought.Subtasks {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "todo",
Content: st.Title,
Checked: st.IsCompleted,
Level: 1,
})
}
}
var metaItems []string
if thought.DueDate != "" {
metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate))
}
if thought.Priority != "" && thought.Priority != "normal" {
metaItems = append(metaItems, "Priorität: "+thought.Priority)
}
if len(metaItems) > 0 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: strings.Join(metaItems, " • "),
Level: 1,
})
}
if len(blocks) == 1 {
blocks = append(blocks, domain.DocumentBlock{
ID: domain.NewID("blk"),
Type: "bullet",
Content: "",
Level: 0,
})
}
doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false)
if err == nil && doc != nil {
convertedRefID = doc.ID
}
} else if convertTo == "list_item" && targetListID != "" {
// Same rule as adding an item in the list itself: owner or editor
if err := s.EnsureListItemsWritable(userID, targetListID, ""); err != nil {
return nil, err
}
newStatus = domain.StatusConverted
convertedType = "list_item"
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
if err == nil && item != nil {
convertedRefID = item.ID
}
}
default:
return nil, fmt.Errorf("invalid triage action: %s", action)
}
_, err = s.db.Exec(
`UPDATE thoughts
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?,
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END,
alarm_failures = CASE WHEN due_date != ? THEN 0 ELSE alarm_failures END
WHERE id = ? AND user_id = ?`,
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, dueDate, thoughtID, userID,
)
if err != nil {
return nil, err
}
if taskListID != thought.TaskListID {
s.PruneTaskAssignees()
}
return s.GetThoughtByID(userID, thoughtID)
}
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask, tags *[]string) (*domain.Thought, error) {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return nil, err
}
wasCompleted := thought.IsCompleted
oldDue := thought.DueDate
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID != nil && *taskListID != thought.TaskListID {
if err := s.ensureTaskListWritable(userID, *taskListID); err != nil {
return nil, err
}
}
if content != nil {
thought.Content = strings.TrimSpace(*content)
thought.Tags = domain.ExtractTags(thought.Content)
}
if tags != nil {
thought.Tags = *tags
}
if isCompleted != nil {
thought.IsCompleted = *isCompleted
if *isCompleted {
thought.KanbanStatus = "done"
} else if thought.KanbanStatus == "done" {
thought.KanbanStatus = "todo"
}
}
if dueDate != nil {
thought.DueDate = domain.NormalizeDue(*dueDate)
}
if priority != nil {
p := strings.ToLower(strings.TrimSpace(*priority))
if p == "" || p == "none" {
p = "normal"
}
thought.Priority = p
}
if taskListID != nil {
thought.TaskListID = *taskListID
}
if kanbanStatus != nil && *kanbanStatus != "" {
thought.KanbanStatus = *kanbanStatus
if isCompleted == nil {
if *kanbanStatus == "done" {
thought.IsCompleted = true
} else {
thought.IsCompleted = false
}
}
}
if notes != nil {
thought.Notes = *notes
}
if subtasks != nil {
thought.Subtasks = *subtasks
}
tagsJSON, _ := json.Marshal(thought.Tags)
subtasksJSON, _ := json.Marshal(thought.Subtasks)
completedInt := 0
if thought.IsCompleted {
completedInt = 1
}
_, err = s.db.Exec(
`UPDATE thoughts SET content = ?, tags = ?, is_completed = ?, due_date = ?, priority = ?, task_list_id = ?, kanban_status = ?, notes = ?, subtasks = ? WHERE id = ?`,
thought.Content, string(tagsJSON), completedInt, thought.DueDate, thought.Priority, thought.TaskListID, thought.KanbanStatus, thought.Notes, string(subtasksJSON), thoughtID,
)
if err != nil {
return nil, err
}
// A new due date or a reopened task gets its alarm again
if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) {
if _, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 0, alarm_failures = 0 WHERE id = ?`, thoughtID); err != nil {
return nil, err
}
thought.AlarmSent = false
}
if taskListID != nil {
s.PruneTaskAssignees()
thought.Assignees = s.getTaskAssignees(thoughtID)
}
if !wasCompleted && thought.IsCompleted {
s.spawnNextOccurrence(thoughtID)
}
return thought, nil
}
func (s *Store) DeleteThought(userID, thoughtID string) error {
thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil {
return err
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return err
}
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
return err
}
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) {
// Linking changes the block (its tick follows the task), so edit rights are needed
if !CanEditDoc(s.DocRole(userID, docID)) {
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
}
t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "")
if err != nil {
return nil, err
}
t.ConvertedType = "document_block"
t.ConvertedRefID = docID + ":" + blockID
t.DocID = docID
t.BlockID = blockID
t.IsCompleted = checked
t.Status = domain.StatusKept
doneInt := 0
if checked {
doneInt = 1
t.KanbanStatus = "done"
}
if _, err := s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID); err != nil {
return nil, err
}
s.SetBlockChecked(userID, docID, blockID, checked)
return t, nil
}
// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened).
// Returns the note ID if the block changed, so the caller can notify its viewers.
func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string {
t, err := s.GetThoughtByID(userID, taskID)
if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") {
return ""
}
docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":")
if s.SetBlockChecked(userID, docID, blockID, isCompleted) {
return docID
}
return ""
}
// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's
// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change.
func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) error {
t, err := s.GetThoughtByID(userID, taskID)
if err != nil || t == nil {
return err
}
if err := s.ensureTaskListWritable(userID, t.TaskListID); err != nil {
return err
}
_, err = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID)
return err
}
func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
if taskID == "" {
return
}
doneInt := 0
kanban := "todo"
if checked {
doneInt = 1
kanban = "done"
}
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END,
alarm_failures = CASE WHEN ? = 0 THEN 0 ELSE alarm_failures END
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, doneInt, taskID, userID, doneInt)
if checked && res != nil {
if n, _ := res.RowsAffected(); n > 0 {
s.spawnNextOccurrence(taskID)
}
}
}
// --- ALARMS ---
type DueTaskAlarm struct {
TaskID string
UserID string
Title string
Notes string
DueDate string
Priority string
UserEmail string
AlarmChannels []string
AlarmEmail string
DiscordWebhookURL string
CustomWebhookURL string
NotifyBeforeMinutes int
}
func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
rows, err := s.db.Query(
`SELECT t.id, t.user_id, t.content, t.notes, t.due_date, t.priority,
u.email, u.alarm_channels, u.alarm_email, u.discord_webhook_url, u.custom_webhook_url, u.notify_before_minutes
FROM thoughts t
JOIN users u ON u.id = t.user_id
WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task'))
AND t.is_completed = 0
AND t.alarm_sent = 0
AND t.due_date != ''
AND u.is_active = 1`,
)
if err != nil {
return []DueTaskAlarm{}, err
}
defer rows.Close()
alarms := make([]DueTaskAlarm, 0)
for rows.Next() {
var a DueTaskAlarm
var channelsRaw string
if err := rows.Scan(
&a.TaskID, &a.UserID, &a.Title, &a.Notes, &a.DueDate, &a.Priority,
&a.UserEmail, &channelsRaw, &a.AlarmEmail, &a.DiscordWebhookURL, &a.CustomWebhookURL, &a.NotifyBeforeMinutes,
); err == nil {
a.AlarmChannels = parseAlarmChannels(channelsRaw)
if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail
}
alarms = append(alarms, a)
}
}
return alarms, nil
}
func (s *Store) MarkTaskAlarmSent(taskID string) error {
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1, alarm_failures = 0 WHERE id = ?`, taskID)
return err
}
// MaxAlarmFailures: after this many runs in which no channel delivered, the alarm is given up instead of
// being retried every minute. A new due date or reopening the task starts over.
const MaxAlarmFailures = 3
// RecordTaskAlarmFailure counts a run without any successful channel and reports whether the alarm was
// given up now.
func (s *Store) RecordTaskAlarmFailure(taskID string) (gaveUp bool, err error) {
var failures int
err = s.db.QueryRow(`UPDATE thoughts SET alarm_failures = alarm_failures + 1,
alarm_sent = CASE WHEN alarm_failures + 1 >= ? THEN 1 ELSE alarm_sent END
WHERE id = ? RETURNING alarm_failures`, MaxAlarmFailures, taskID).Scan(&failures)
return err == nil && failures >= MaxAlarmFailures, err
}