MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
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>
670 lines
21 KiB
Go
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
|
|
}
|