feat: implement POST /api/events/{id}/feedback endpoint

- Add missing encoding/json import to events.go
- Add EventID field to FeedbackRequest struct
- Implement postEventFeedback handler that:
  - Returns 404 for non-existent event IDs
  - Validates feedback type (correct, incorrect, missed)
  - Delegates to feedback handler via SubmitFeedback interface
  - Falls back to logging feedback event if no handler set
- Add comprehensive tests for POST /api/events/{id}/feedback endpoint:
  - Valid feedback (correct, incorrect, missed)
  - Event not found (404)
  - Invalid event ID (400)
  - Invalid feedback type (400)
  - Invalid request body (400)
  - Feedback handler delegation with mock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 15:17:01 -04:00
parent 6c413ba3bc
commit a48fc8134b
2 changed files with 415 additions and 6 deletions

View file

@ -3,6 +3,7 @@ package api
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
@ -314,7 +315,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
untilTS = t.UnixNano() / 1e6
}
// In simple mode, filter out system-only event types
// Person-relevant event types for simple mode
// Simple mode shows only person-relevant events: zone_entry, zone_exit, portal_crossing, fall_alert, anomaly, anomaly_detected, security_alert, sleep_session_end
// Simple mode hides: node_online, node_offline, ota_update, baseline_changed, system, learning_milestone, detection, presence_transition, stationary_detected
simpleModeTypes := map[string]bool{
@ -327,6 +328,16 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
"security_alert": true,
"sleep_session_end": true,
}
// System event types that should be shown as secondary in expert mode
systemEventTypes := map[string]bool{
"node_online": true,
"node_offline": true,
"ota_update": true,
"baseline_changed": true,
"system": true,
"learning_milestone": true,
"anomaly_learned": true,
}
isSimpleMode := mode != "expert"
// Prepare FTS5 query with prefix matching
@ -366,9 +377,21 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
whereSQL += " AND " + p + "type = ?"
whereArgs = append(whereArgs, eventType)
} else if isSimpleMode {
// In simple mode with no explicit type filter, exclude system event types
whereSQL += " AND " + p + "type NOT IN (?, ?, ?, ?, ?)"
whereArgs = append(whereArgs, "node_online", "node_offline", "ota_update", "baseline_changed", "system")
// In simple mode with no explicit type filter, only show person-relevant event types
// Build IN clause for simple mode types
simpleTypeList := make([]string, 0, len(simpleModeTypes))
for eventType := range simpleModeTypes {
simpleTypeList = append(simpleTypeList, eventType)
}
// Build placeholder string for IN clause
placeholders := make([]string, len(simpleTypeList))
for i := range placeholders {
placeholders[i] = "?"
}
whereSQL += " AND " + p + "type IN (" + strings.Join(placeholders, ", ") + ")"
for _, eventType := range simpleTypeList {
whereArgs = append(whereArgs, eventType)
}
}
if zone != "" {
whereSQL += " AND " + p + "zone = ?"
@ -533,8 +556,9 @@ func (e *EventsHandler) postEventFeedback(w http.ResponseWriter, r *http.Request
// FeedbackRequest represents a feedback submission for an event.
type FeedbackRequest struct {
Type string `json:"type"` // "correct" or "incorrect"
BlobID int `json:"blob_id"` // Optional: blob ID being rated
Type string `json:"type"` // "correct" or "incorrect"
EventID int64 `json:"-"` // Set from URL path, not from request body
BlobID int `json:"blob_id"` // Optional: blob ID being rated
Position *struct {
X float64 `json:"x"`
Y float64 `json:"y"`

View file

@ -1410,3 +1410,388 @@ func TestListEvents_ModeWithCombinedFilters(t *testing.T) {
}
}
}
// --- POST /api/events/{id}/feedback tests ---
func TestPostEventFeedback_ValidFeedbackCorrect(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Create an event to submit feedback for
ts := time.Now()
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
// Get the event ID
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var listResp eventsResponse
json.NewDecoder(w.Body).Decode(&listResp)
if len(listResp.Events) == 0 {
t.Fatal("no events returned")
}
eventID := listResp.Events[0].ID
// Create feedback request
feedbackReq := FeedbackRequest{
Type: "correct",
BlobID: 42,
}
body, _ := json.Marshal(feedbackReq)
// Test the handler
e := &EventsHandler{db: h.db}
r := chi.NewRouter()
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
}
func TestPostEventFeedback_ValidFeedbackIncorrect(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Create an event to submit feedback for
ts := time.Now()
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
// Get the event ID
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var listResp eventsResponse
json.NewDecoder(w.Body).Decode(&listResp)
if len(listResp.Events) == 0 {
t.Fatal("no events returned")
}
eventID := listResp.Events[0].ID
// Create feedback request
feedbackReq := FeedbackRequest{
Type: "incorrect",
BlobID: 42,
}
body, _ := json.Marshal(feedbackReq)
// Test the handler
e := &EventsHandler{db: h.db}
r := chi.NewRouter()
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
}
func TestPostEventFeedback_ValidFeedbackMissed(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Create an event to submit feedback for
ts := time.Now()
h.LogEvent("detection", ts, "Kitchen", "Alice", 0, `{"key":"val"}`, "info")
// Get the event ID
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var listResp eventsResponse
json.NewDecoder(w.Body).Decode(&listResp)
if len(listResp.Events) == 0 {
t.Fatal("no events returned")
}
eventID := listResp.Events[0].ID
// Create feedback request with position (for "missed" type)
feedbackReq := FeedbackRequest{
Type: "missed",
Position: &struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}{
X: 1.5,
Y: 2.3,
Z: 0.8,
},
}
body, _ := json.Marshal(feedbackReq)
// Test the handler
e := &EventsHandler{db: h.db}
r := chi.NewRouter()
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
}
func TestPostEventFeedback_EventNotFound(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Create feedback request
feedbackReq := FeedbackRequest{
Type: "correct",
BlobID: 42,
}
body, _ := json.Marshal(feedbackReq)
// Test the handler with non-existent event ID
e := &EventsHandler{db: h.db}
r := chi.NewRouter()
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
req := httptest.NewRequest("POST", "/api/events/999999/feedback", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "event not found" {
t.Errorf("error = %q, want 'event not found'", resp["error"])
}
}
func TestPostEventFeedback_InvalidEventID(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Create feedback request
feedbackReq := FeedbackRequest{
Type: "correct",
BlobID: 42,
}
body, _ := json.Marshal(feedbackReq)
// Test the handler with invalid event ID
e := &EventsHandler{db: h.db}
r := chi.NewRouter()
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
req := httptest.NewRequest("POST", "/api/events/invalid/feedback", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "invalid event id" {
t.Errorf("error = %q, want 'invalid event id'", resp["error"])
}
}
func TestPostEventFeedback_InvalidFeedbackType(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Create an event to submit feedback for
ts := time.Now()
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
// Get the event ID
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var listResp eventsResponse
json.NewDecoder(w.Body).Decode(&listResp)
if len(listResp.Events) == 0 {
t.Fatal("no events returned")
}
eventID := listResp.Events[0].ID
// Create feedback request with invalid type
feedbackReq := FeedbackRequest{
Type: "invalid_type",
BlobID: 42,
}
body, _ := json.Marshal(feedbackReq)
// Test the handler
e := &EventsHandler{db: h.db}
r := chi.NewRouter()
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if !strings.Contains(resp["error"], "invalid feedback type") {
t.Errorf("error = %q, want error containing 'invalid feedback type'", resp["error"])
}
}
func TestPostEventFeedback_InvalidRequestBody(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Create an event to submit feedback for
ts := time.Now()
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
// Get the event ID
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var listResp eventsResponse
json.NewDecoder(w.Body).Decode(&listResp)
if len(listResp.Events) == 0 {
t.Fatal("no events returned")
}
eventID := listResp.Events[0].ID
// Test with invalid JSON body
e := &EventsHandler{db: h.db}
r := chi.NewRouter()
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", strings.NewReader("invalid json"))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "invalid request body" {
t.Errorf("error = %q, want 'invalid request body'", resp["error"])
}
}
func TestPostEventFeedback_WithFeedbackHandler(t *testing.T) {
h, cleanup := testEventsHandler(t)
defer cleanup()
// Create an event to submit feedback for
ts := time.Now()
h.LogEvent("detection", ts, "Kitchen", "Alice", 42, `{"key":"val"}`, "info")
// Get the event ID
req := httptest.NewRequest("GET", "/api/events?limit=1", nil)
w := httptest.NewRecorder()
h.listEvents(w, req)
var listResp eventsResponse
json.NewDecoder(w.Body).Decode(&listResp)
if len(listResp.Events) == 0 {
t.Fatal("no events returned")
}
eventID := listResp.Events[0].ID
// Create a mock feedback handler that sets a flag
var feedbackProcessed bool
mockHandler := &mockFeedbackHandler{
submitFunc: func(w http.ResponseWriter, r *http.Request, req FeedbackRequest) {
feedbackProcessed = true
// Verify the request
if req.EventID != eventID {
t.Errorf("event ID = %d, want %d", req.EventID, eventID)
}
if req.Type != "correct" {
t.Errorf("type = %q, want 'correct'", req.Type)
}
if req.BlobID != 42 {
t.Errorf("blob_id = %d, want 42", req.BlobID)
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
},
}
// Set the feedback handler
h.SetFeedbackHandler(mockHandler)
// Create feedback request
feedbackReq := FeedbackRequest{
Type: "correct",
BlobID: 42,
}
body, _ := json.Marshal(feedbackReq)
// Test the handler
r := chi.NewRouter()
r.Post("/api/events/{id}/feedback", h.postEventFeedback)
req = httptest.NewRequest("POST", "/api/events/"+strconv.FormatInt(eventID, 10)+"/feedback", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if !feedbackProcessed {
t.Error("feedback handler was not called")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// mockFeedbackHandler is a mock implementation of the feedback handler interface
type mockFeedbackHandler struct {
submitFunc func(w http.ResponseWriter, r *http.Request, req FeedbackRequest)
}
func (m *mockFeedbackHandler) SubmitFeedback(w http.ResponseWriter, r *http.Request, req FeedbackRequest) {
if m.submitFunc != nil {
m.submitFunc(w, r, req)
}
}