Files
MindSift/internal/api/handlers_thoughts.go
T
sandrodachandClaude Opus 5.5 994f6dc554
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m37s
feat(search): kind chips, large view with filters and context, tick tasks and list items right in the results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:53:10 +01:00

434 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"encoding/json"
"errors"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Content string `json:"content"`
Priority string `json:"priority"`
Source string `json:"source"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
Target string `json:"target"` // inbox | task | list_item | document
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_list_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
targetListID := req.TargetListID
if targetListID == "" {
targetListID = req.TargetList2
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
source := strings.ToLower(strings.TrimSpace(req.Source))
if source == "" {
source = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source")))
}
if source == "" {
source = strings.ToLower(strings.TrimSpace(r.Header.Get("X-Source")))
}
if source == "" {
// When called via API key or webhook token, automatically default source to "webhook"
if r.Header.Get("X-API-Key") != "" ||
strings.HasPrefix(r.Header.Get("Authorization"), "Bearer mindsift_sec_") ||
strings.HasPrefix(r.URL.Query().Get("token"), "mindsift_sec_") {
source = "webhook"
} else {
source = "manual"
}
}
if !requireDeliveryScope(w, r, strings.TrimSpace(req.Target)) {
return
}
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
// Before a conversion, so a list item made from it takes the origin along
s.recordThoughtOrigin(thought, actorFrom(r.Context()), true)
target := strings.TrimSpace(req.Target)
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
thought = converted
if target == "list_item" && targetListID != "" {
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
}
}
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
status := r.URL.Query().Get("status")
thoughts, err := s.store.GetThoughts(userID, status, 250)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.markWatching(userID, thoughts)
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleGetThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
thought, err := s.store.GetThoughtByID(userID, thoughtID)
if err != nil || thought == nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
writeJSON(w, http.StatusOK, thought)
}
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, thoughts)
}
func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Action string `json:"action"` // keep | discard | restore
ConvertTo string `json:"convertTo"`
ConvertTo2 string `json:"convert_to"`
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_list_id"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
convertTo := req.ConvertTo
if convertTo == "" {
convertTo = req.ConvertTo2
}
targetListID := req.TargetListID
if targetListID == "" {
targetListID = req.TargetList2
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
// If converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
s.wsHub.BroadcastToUsers(s.taskAudience(updated), "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
q := r.URL.Query()
tasks, err := s.store.GetTasks(userID, q.Get("taskListId"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
tasks, err = filterTasks(tasks, q.Get("status"), q.Get("due"), time.Now())
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, tasks)
}
// filterTasks narrows GET /tasks for scripts and smart homes; without parameters nothing changes.
// status: open | done | all (default). due: today | overdue | YYYY-MM-DD (due on or before that day).
// Days count in the server's time zone like due dates themselves (now is passed in for tests).
func filterTasks(tasks []domain.Thought, status, due string, now time.Time) ([]domain.Thought, error) {
switch status {
case "", "all", "open", "done":
default:
return nil, errors.New("status: open, done oder all")
}
today := now.In(time.Local).Format("2006-01-02")
var match func(day string) bool
switch {
case due == "":
case due == "today":
match = func(day string) bool { return day == today }
case due == "overdue":
match = func(day string) bool { return day < today }
default:
if _, err := time.Parse("2006-01-02", due); err != nil {
return nil, errors.New("due: today, overdue oder ein Datum JJJJ-MM-TT")
}
match = func(day string) bool { return day <= due }
}
out := make([]domain.Thought, 0, len(tasks))
for _, t := range tasks {
if (status == "open" && t.IsCompleted) || (status == "done" && !t.IsCompleted) {
continue
}
// Overdue only makes sense for open tasks
if due == "overdue" && t.IsCompleted {
continue
}
if match != nil && (t.DueDate == "" || !match(t.DueDate[:min(10, len(t.DueDate))])) {
continue
}
out = append(out, t)
}
return out, nil
}
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := chi.URLParam(r, "id")
if taskListID == "" {
taskListID = r.URL.Query().Get("taskListId")
}
var req struct {
Items []domain.ThoughtReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
// ReorderTasks updates by ID only, so every item must be a task the caller may edit
audience := []string{userID}
for _, it := range req.Items {
th, err := s.store.GetThoughtByID(userID, it.ID)
if err != nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
if th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
audience = append(audience, s.taskAudience(th)...)
}
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(audience, "TASK_REORDERED", map[string]interface{}{
"taskListId": taskListID,
"items": req.Items,
"userId": userID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// taskPatch is a partial change of a task; nil fields stay as they are. Shared by PATCH /thoughts/{id}
// and the MCP tools.
type taskPatch struct {
Content *string `json:"content"`
IsCompleted *bool `json:"isCompleted"`
DueDate *string `json:"dueDate"`
Priority *string `json:"priority"`
TaskListID *string `json:"taskListId"`
KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"`
// actor: the API key making the change (recorded as the task's origin); nil for the app
actor *apiActor
}
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
// and sends the notifications. Errors come from the store (writeStoreError maps them).
func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Thought, error) {
before, _ := s.store.GetThoughtByID(userID, thoughtID)
// The rule is set first, so completing a task in the same request already repeats it
if req.Recurrence != nil {
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
return nil, err
}
}
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil {
return nil, err
}
s.recordThoughtOrigin(updated, req.actor, false)
if req.Assignees != nil {
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
if err != nil {
return nil, err
}
updated.Assignees = assignees
}
if docID := s.store.SyncTaskToDoc(userID, thoughtID); docID != "" {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
}
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
s.notifyTaskChange(userID, before, updated, false)
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
return updated, nil
}
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req taskPatch
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
req.actor = actorFrom(r.Context())
updated, err := s.updateTask(userID, thoughtID, req)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
if err := s.store.UnlinkTaskFromDocBlock(userID, thoughtID); err != nil {
writeStoreError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
}
// deleteTask removes a task, tells everyone who saw it and notifies its watchers. Shared by
// DELETE /thoughts/{id} and the MCP tool delete_task.
func (s *Server) deleteTask(userID, thoughtID string) error {
th, err := s.store.GetThoughtByID(userID, thoughtID)
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
return storage.ErrTaskListReadOnly
}
audience := s.taskAudience(th)
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
return err
}
if th != nil {
s.notifyTaskChange(userID, th, nil, true)
}
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
return nil
}
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if err := s.deleteTask(userID, chi.URLParam(r, "id")); err != nil {
writeStoreError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
count, err := s.store.PurgeDiscardedThoughts(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"purged": count})
}
// --- SEARCH & EXPORT ---
// handleSearch: GET /search?q= – everything, or narrowed by type (document|thought|list_item), limit
// (hits per kind, up to 200) and filters: taskStatus, taskListId, priority, due (today|overdue|week),
// folder, listId, itemStatus.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
qs := r.URL.Query()
q := qs.Get("q")
opts := storage.SearchOptions{
Type: qs.Get("type"),
TaskStatus: qs.Get("taskStatus"),
TaskListID: qs.Get("taskListId"),
Priority: qs.Get("priority"),
Due: qs.Get("due"),
Today: time.Now().In(time.Local).Format("2006-01-02"),
Folder: qs.Get("folder"),
ListID: qs.Get("listId"),
ItemStatus: qs.Get("itemStatus"),
}
if opts.Type != "" && !slices.Contains(storage.SearchTypes, opts.Type) {
writeError(w, http.StatusBadRequest, "type: document, thought oder list_item")
return
}
if n, err := strconv.Atoi(qs.Get("limit")); err == nil {
opts.Limit = n
}
res, err := s.store.Search(userID, q, opts)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}