diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index a90a97f..ec8e15b 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -264,6 +264,11 @@ export const UserSettingsView: React.FC = ({ const activeKeyPlaceholder = newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY'); + const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ + -H "X-API-Key: ${activeKeyPlaceholder}" \\ + -H "Content-Type: application/json" \\ + -d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`; + const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ -H "X-API-Key: ${activeKeyPlaceholder}" \\ -H "Content-Type: application/json" \\ @@ -920,23 +925,43 @@ export const UserSettingsView: React.FC = ({ )} - {/* Curl Example */} -
-
- - - Curl-Beispiel: Direkt als Aufgabe einspeisen - - + {/* Curl Examples */} +
+
+
+ + + Curl: In Triage-Deck einspeisen (erhält Webhook-Badge) + + +
+
+                    {curlTriageExample}
+                  
+
+ +
+
+ + + Curl: Direkt als Aufgabe anlegen + + +
+
+                    {curlTaskExample}
+                  
-
-                  {curlTaskExample}
-                
{/* Full Data Backup */} diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 6ce2e3e..7e1b3ef 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -14,6 +14,7 @@ import { Flame, ChevronRight, Folder, + Webhook, } from 'lucide-react'; import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; import { triggerHaptic } from '../../lib/api'; @@ -192,6 +193,12 @@ export const TaskDetailModal: React.FC = ({ {task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} + {task.source?.toLowerCase() === 'webhook' && ( + + + Webhook + + )}
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 75ea26b..de19dd4 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -24,6 +24,7 @@ import { ChevronDown, Folder, Filter, + Webhook, } from 'lucide-react'; import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; @@ -1170,6 +1171,16 @@ export const TasksView: React.FC = ({ )} + {task.source?.toLowerCase() === 'webhook' && ( + + + Webhook + + )} + {/* Note indicator */} {task.notes && ( )} + {task.source?.toLowerCase() === 'webhook' && ( + + + Webhook + + )} {task.dueDate && ( diff --git a/frontend/src/features/triage/TriageDeck.tsx b/frontend/src/features/triage/TriageDeck.tsx index 22aa1f4..51ccec9 100644 --- a/frontend/src/features/triage/TriageDeck.tsx +++ b/frontend/src/features/triage/TriageDeck.tsx @@ -139,11 +139,21 @@ const SwipeableQueueItem: React.FC = ({ }} className="relative bg-panel px-4 py-3.5 sm:px-3.5 sm:py-2.5 min-h-[56px] sm:min-h-[44px] flex items-center justify-between gap-3 hover:bg-panel-hover transition-colors group cursor-pointer" > -
+
{item.content} + {item.source?.toLowerCase() === 'webhook' && ( + + Webhook + + )} + {item.source?.toLowerCase() === 'voice' && ( + + Diktat + + )}
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */} @@ -413,15 +423,16 @@ export const TriageDeck: React.FC = ({ } }; - const sourceBadge = (source: string) => { - if (source === 'voice') { + const sourceBadge = (source?: string) => { + const s = (source || '').toLowerCase().trim(); + if (s === 'voice') { return ( Diktat ); } - if (source === 'webhook') { + if (s === 'webhook') { return ( Webhook @@ -500,7 +511,10 @@ export const TriageDeck: React.FC = ({ key={nextCard.id + '-preview'} className="absolute inset-x-3 top-3 bottom-0 panel-surface p-4 opacity-40 scale-[0.97] pointer-events-none flex flex-col justify-between" > -
{priorityBadge(nextCard.priority)}
+
+ {priorityBadge(nextCard.priority)} + {sourceBadge(nextCard.source)} +

{nextCard.content}

Nächster Quick Dump…
diff --git a/internal/api/server.go b/internal/api/server.go index d7993fe..fc0f54f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "io" "io/fs" "net/http" "net/smtp" @@ -586,7 +587,25 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) { taskListID = req.TaskListID2 } - thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, req.Source, dueDate, taskListID, "todo", "") + 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" + } + } + + thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "") if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -1689,13 +1708,36 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { var req struct { Title string `json:"title"` Content string `json:"content"` + Text string `json:"text"` + Message string `json:"message"` Priority string `json:"priority"` + Source string `json:"source"` Tags []string `json:"tags"` 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-Payload") - return + + bodyBytes, _ := io.ReadAll(r.Body) + if len(bodyBytes) > 0 { + if jsonErr := json.Unmarshal(bodyBytes, &req); jsonErr != nil { + plain := strings.TrimSpace(string(bodyBytes)) + if plain != "" { + req.Content = plain + } + } + } + + if req.Content == "" { + if req.Text != "" { + req.Content = req.Text + } else if req.Message != "" { + req.Content = req.Message + } + } + + dueDate := req.DueDate + if dueDate == "" { + dueDate = req.DueDate2 } bodyText := strings.TrimSpace(req.Title) @@ -1707,7 +1749,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { } } if bodyText == "" { - writeError(w, http.StatusBadRequest, "Feld 'title' oder 'content' ist erforderlich") + writeError(w, http.StatusBadRequest, "Feld 'title', 'content', 'text' oder 'message' ist erforderlich") return } @@ -1722,12 +1764,19 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { req.Priority = "high" } - thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, "webhook", req.DueDate) + source := strings.ToLower(strings.TrimSpace(req.Source)) + if source == "" { + source = "webhook" + } + + thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought) + writeJSON(w, http.StatusCreated, map[string]interface{}{ "status": "ingested", "thought": thought, diff --git a/internal/api/server_test.go b/internal/api/server_test.go index cf96baf..69d228d 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -747,3 +747,94 @@ func TestWorkspaceTagsAPI(t *testing.T) { t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code) } } + +func TestWebhookTriageBadgeAndIngestion(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test_webhook.db") + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatalf("failed to init store: %v", err) + } + defer store.Close() + + cfg := &config.Config{ + JWTSecret: "test-jwt-secret-key", + AllowRegistration: false, + } + srv := NewServer(cfg, store, ws.NewHub(), nil) + router := srv.Router() + + // 1. Setup user + setupBody := []byte(`{ + "email": "smarthome_owner@example.com", + "password": "secretpassword", + "displayName": "Home User", + "instanceName": "MindSift Home", + "allowRegistration": false, + "seedSampleData": false + }`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("setup failed: %s", rec.Body.String()) + } + var authResp struct { + Token string `json:"token"` + User *domain.User `json:"user"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &authResp) + + // 2. Create API key for user + apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook") + if err != nil || apiKey == nil || apiKey.RawKey == "" { + t.Fatalf("failed to create API key: %v", err) + } + plainKey := apiKey.RawKey + + // 3. Post to /api/v1/inbox using X-API-Key without passing "source" + inboxBody := []byte(`{ + "content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", + "priority": "urgent" + }`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(inboxBody)) + req.Header.Set("X-API-Key", plainKey) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created from /api/v1/inbox with API key, got %d: %s", rec.Code, rec.Body.String()) + } + var createdThought domain.Thought + _ = json.Unmarshal(rec.Body.Bytes(), &createdThought) + if createdThought.Source != "webhook" { + t.Fatalf("expected created thought to have source='webhook' when called with API key, got '%s'", createdThought.Source) + } + + // 4. Post to /api/v1/webhooks/ingest using ?token=... with message alias + webhookBody := []byte(`{ + "message": "Waschmaschine im Keller fertig", + "priority": "high", + "tags": ["automation"] + }`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/ingest?token="+plainKey, bytes.NewReader(webhookBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 Created from /api/v1/webhooks/ingest, got %d: %s", rec.Code, rec.Body.String()) + } + var ingestResp struct { + Status string `json:"status"` + Thought domain.Thought `json:"thought"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &ingestResp) + if ingestResp.Thought.Source != "webhook" { + t.Fatalf("expected ingested webhook thought to have source='webhook', got '%s'", ingestResp.Thought.Source) + } + if !strings.Contains(ingestResp.Thought.Content, "Waschmaschine") { + t.Fatalf("expected thought content to contain 'Waschmaschine', got '%s'", ingestResp.Thought.Content) + } +}