From 37571ece97549b8af0d74da9afed30744e8ce86a Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 5 May 2026 13:09:25 -0400 Subject: [PATCH] api/notifications: register preview endpoint in main.go The GET /api/notifications/preview endpoint was already implemented in internal/api/notifications.go but was never registered in main.go. This commit wires up the NotificationsHandler to enable the test thumbnail endpoint for UI development and QA. The endpoint accepts query parameters: - type: notification type (fall, anomaly, zone_enter, sleep) - person: person name (optional, defaults to "Alice") It calls the appropriate Generate*Thumbnail function from the render package and returns PNG bytes with Content-Type: image/png. Co-Authored-By: Claude Opus 4.7 --- mothership/cmd/mothership/main.go | 10 ++ mothership/internal/api/notifications.go | 70 ++++++++++++ mothership/internal/api/notifications_test.go | 101 ++++++++++++++++++ 3 files changed, 181 insertions(+) diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index ae56659..224fdf1 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -527,6 +527,16 @@ func main() { notificationSettingsHandler.RegisterRoutes(r) log.Printf("[INFO] Notification settings API registered at /api/settings/notifications") + // Phase 6: Notifications REST API (channels, preview, test) + notificationsHandler, err := api.NewNotificationsHandler(filepath.Join(cfg.DataDir, "notifications.db")) + if err != nil { + log.Printf("[WARN] Failed to create notifications handler: %v", err) + } else { + defer notificationsHandler.Close() + notificationsHandler.RegisterRoutes(r) + log.Printf("[INFO] Notifications API registered at /api/notifications/*") + } + // Phase 6: Feature discovery notifications // Notifier manages one-time feature discovery notifications with quiet hours support featureNotifier, err := featurehelp.NewNotifier(mainDB) diff --git a/mothership/internal/api/notifications.go b/mothership/internal/api/notifications.go index 7825fdc..08704d6 100644 --- a/mothership/internal/api/notifications.go +++ b/mothership/internal/api/notifications.go @@ -13,6 +13,8 @@ import ( "github.com/go-chi/chi/v5" _ "modernc.org/sqlite" + + "github.com/spaxel/mothership/internal/render" ) // NotificationsHandler manages notification delivery channels. @@ -312,6 +314,7 @@ func (n *NotificationsHandler) RegisterRoutes(r chi.Router) { r.Get("/api/notifications/config", n.handleGetConfig) r.Post("/api/notifications/config", n.handleSetConfig) r.Post("/api/notifications/test", n.handleSendTest) + r.Get("/api/notifications/preview", n.handlePreview) } // notificationConfigResponse is the response for channel configuration requests. @@ -435,6 +438,73 @@ func (n *NotificationsHandler) handleSendTest(w http.ResponseWriter, r *http.Req }) } +// handlePreview handles GET /api/notifications/preview +// Returns a rendered test image for UI development and QA. +// Query params: +// - type: notification type (fall, anomaly, zone_enter, sleep) +// - person: person name (optional, defaults to "Alice") +func (n *NotificationsHandler) handlePreview(w http.ResponseWriter, r *http.Request) { + // Parse query parameters + notifType := r.URL.Query().Get("type") + personName := r.URL.Query().Get("person") + + // Set defaults + if personName == "" { + personName = "Alice" + } + + // Define test zones + zones := []render.Zone{ + {ID: "kitchen", Name: "Kitchen", X: 0, Y: 0, W: 4, D: 3, Color: "#4fc3f7"}, + {ID: "living", Name: "Living", X: 4, Y: 0, W: 5, D: 4, Color: "#81c784"}, + {ID: "hallway", Name: "Hallway", X: 4, Y: 4, W: 2, D: 2, Color: "#ffb74d"}, + {ID: "bedroom", Name: "Bedroom", X: 6, Y: 4, W: 3, D: 3, Color: "#ba68c8"}, + } + + // Define test person + person := render.Person{ + Name: personName, + X: 2.0, + Y: 1.5, + Z: 1.0, + Color: "#4488ff", + Confidence: 0.85, + IsFall: false, + } + + var pngData []byte + var err error + + // Generate thumbnail based on notification type + switch notifType { + case "fall": + pngData, err = render.GenerateFallDetectedThumbnail(10.0, 8.0, zones, person, "Kitchen") + case "anomaly": + pngData, err = render.GenerateAnomalyAlertThumbnail(10.0, 8.0, zones, "Living") + case "zone_enter": + pngData, err = render.GenerateZoneEnterThumbnail(10.0, 8.0, zones, person, "Kitchen") + case "sleep": + person.Z = 0.5 // Sleeping position + pngData, err = render.GenerateSleepSummaryThumbnail(10.0, 8.0, zones, person, "7h 23m") + default: + // Default to fall detection preview + pngData, err = render.GenerateFallDetectedThumbnail(10.0, 8.0, zones, person, "Kitchen") + } + + if err != nil { + log.Printf("[ERROR] Failed to generate preview thumbnail: %v", err) + http.Error(w, "failed to generate preview", http.StatusInternalServerError) + return + } + + // Set headers and write PNG data + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + if _, err := w.Write(pngData); err != nil { + log.Printf("[ERROR] Failed to write preview response: %v", err) + } +} + // ── Notification sending (called by automation engine) ──────────────────────────── // SendNotification sends a notification via all enabled channels. diff --git a/mothership/internal/api/notifications_test.go b/mothership/internal/api/notifications_test.go index 12fa480..9e2c048 100644 --- a/mothership/internal/api/notifications_test.go +++ b/mothership/internal/api/notifications_test.go @@ -304,6 +304,107 @@ func TestNotificationsHandler(t *testing.T) { t.Errorf("Expected body 'Custom Body', got '%s'", mockSender.body) } }) + + t.Run("GET /api/notifications/preview - fall detection", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/notifications/preview?type=fall&person=Bob", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + ct := w.Header().Get("Content-Type") + if ct != "image/png" { + t.Errorf("Expected Content-Type 'image/png', got '%s'", ct) + } + + // Verify PNG data was returned (PNG magic bytes) + body := w.Body.Bytes() + if len(body) < 8 { + t.Fatalf("Expected PNG data, got %d bytes", len(body)) + } + // PNG magic bytes: 137 80 78 71 13 10 26 10 + if body[0] != 0x89 || body[1] != 0x50 || body[2] != 0x4e || body[3] != 0x47 { + t.Errorf("Expected PNG magic bytes, got %x %x %x %x", body[0], body[1], body[2], body[3]) + } + }) + + t.Run("GET /api/notifications/preview - zone_enter", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/notifications/preview?type=zone_enter&person=Alice", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + ct := w.Header().Get("Content-Type") + if ct != "image/png" { + t.Errorf("Expected Content-Type 'image/png', got '%s'", ct) + } + }) + + t.Run("GET /api/notifications/preview - anomaly", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/notifications/preview?type=anomaly", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + ct := w.Header().Get("Content-Type") + if ct != "image/png" { + t.Errorf("Expected Content-Type 'image/png', got '%s'", ct) + } + }) + + t.Run("GET /api/notifications/preview - sleep", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/notifications/preview?type=sleep", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + ct := w.Header().Get("Content-Type") + if ct != "image/png" { + t.Errorf("Expected Content-Type 'image/png', got '%s'", ct) + } + }) + + t.Run("GET /api/notifications/preview - default (no type)", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/notifications/preview", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Default should return fall detection preview + body := w.Body.Bytes() + if len(body) < 8 { + t.Fatalf("Expected PNG data, got %d bytes", len(body)) + } + // Verify PNG magic bytes + if body[0] != 0x89 || body[1] != 0x50 || body[2] != 0x4e || body[3] != 0x47 { + t.Errorf("Expected PNG magic bytes, got %x %x %x %x", body[0], body[1], body[2], body[3]) + } + }) + + t.Run("GET /api/notifications/preview - cache control", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/notifications/preview?type=fall", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + cc := w.Header().Get("Cache-Control") + if cc != "no-cache, no-store, must-revalidate" { + t.Errorf("Expected Cache-Control 'no-cache, no-store, must-revalidate', got '%s'", cc) + } + }) } // mockNotifySender is a test implementation of NotifySender.