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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-05 13:09:25 -04:00
parent 45aa553a1f
commit 37571ece97
3 changed files with 181 additions and 0 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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.