MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
- Per-user routing (own mail / own Discord webhook) for task alarms
(sent at once), shares, completed shared tasks and watched tasks;
settings as JSON on the user (GET /auth/me, PATCH /auth/settings).
- Everything but alarms is queued and sent as one message per user
after NOTIFY_DIGEST_MINUTES (default 5); mails now multipart with an
HTML version, Discord as one embed. Three failed attempts drop it.
- Own changes never notify the actor, except on watched tasks when
asked for; "paused" stops everything including alarms.
- Task watchers: POST/DELETE /tasks/{id}/watch, isWatching on tasks.
- Daily summary at a chosen time: overdue, today, tomorrow, plus the
count of the next 7 days; nothing due, nothing sent.
- Migration 7; jobs do not run in the demo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
11 KiB
Go
339 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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
|
|
}
|
|
|
|
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())
|
|
taskListID := r.URL.Query().Get("taskListId")
|
|
tasks, err := s.store.GetTasks(userID, taskListID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tasks)
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
thoughtID := chi.URLParam(r, "id")
|
|
|
|
var req 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"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
|
|
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 {
|
|
writeStoreError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
}
|
|
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 {
|
|
writeStoreError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Assignees != nil {
|
|
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
|
if err != nil {
|
|
writeStoreError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
updated.Assignees = assignees
|
|
}
|
|
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); 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]
|
|
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})
|
|
}
|
|
|
|
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
thoughtID := chi.URLParam(r, "id")
|
|
th, err := s.store.GetThoughtByID(userID, thoughtID)
|
|
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
|
|
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
|
return
|
|
}
|
|
audience := s.taskAudience(th)
|
|
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
|
writeStoreError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if th != nil {
|
|
s.notifyTaskChange(userID, th, nil, true)
|
|
}
|
|
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
|
|
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 ---
|
|
|
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
q := r.URL.Query().Get("q")
|
|
res, err := s.store.SearchGlobal(userID, q)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
}
|