refactor: split sqlite.go and server.go by domain (FLAW-20)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
Pure moves, no behaviour change: the storage layer now has users, apikeys, thoughts, tasklists, documents, folders, tags, lists, teams and system files next to sqlite.go (store, schema, migrations); the API has handlers_*.go per area plus realtime.go and alarms.go next to server.go (server, router, middleware). Declarations were moved verbatim with an AST-based tool; imports pruned with goimports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7bba174454
commit
74c394efaa
26 files changed
+7702
-7467
No files matched your search
@@ -0,0 +1,639 @@
|
||||
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 WHERE id = ?`,
|
||||
it.SortOrder, *it.KanbanStatus, 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 != "" {
|
||||
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
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, 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) {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID)
|
||||
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"
|
||||
}
|
||||
_, _ = 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)
|
||||
|
||||
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) {
|
||||
if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID)
|
||||
}
|
||||
|
||||
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
|
||||
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, 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 WHERE id = ?`, taskID)
|
||||
return err
|
||||
}
|
||||
Reference in new issue
Block a user