spaxel/mothership/internal/api/notification_settings.go
jedarden 76156b9c22 test(notify): add comprehensive tests for notification system
- Floor-plan renderer: 300x300 PNG dimensions, zone boundary pixel
  coordinates, blob colors (identified green, fall red, unknown blue)
- Batching: 3 LOW events produce 1 merged notification; URGENT bypasses
  batch queue and sends immediately
- Quiet hours gate: LOW suppressed, URGENT delivered; midnight-crossing
  range handled correctly
- Morning digest: queued events bundled and sent at digest time;
  sendMorningDigest clears queue and sets digestSentToday flag
- ntfy delivery: mock HTTP server verifies Title/Tags/Priority headers
  and body; image attachment in X-Image header
- Webhook delivery: JSON structure verified, base64 PNG image field
  decoded correctly; custom headers forwarded
- Test-notification endpoint: integration tests for ntfy and webhook
  channels with real HTTP mock servers
- Coverage: 81.2% on internal/notify (exceeds 80% requirement)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:42:26 -04:00

550 lines
17 KiB
Go

// Package api provides REST API handlers for Spaxel notification settings.
package api
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
)
// NotificationSettingsHandler manages notification settings.
// These settings are stored in the settings table alongside other system settings.
type NotificationSettingsHandler struct {
mu sync.RWMutex
db *sql.DB
notifyService NotificationTestSender
}
// NotificationTestSender is the interface for sending test notifications.
type NotificationTestSender interface {
Send(title, body string, data map[string]interface{}) error
}
// NewNotificationSettingsHandler creates a new notification settings handler.
func NewNotificationSettingsHandler(db *sql.DB) *NotificationSettingsHandler {
return &NotificationSettingsHandler{
db: db,
}
}
// SetNotifyService sets the notification sender for test notifications.
func (h *NotificationSettingsHandler) SetNotifyService(ns NotificationTestSender) {
h.mu.Lock()
defer h.mu.Unlock()
h.notifyService = ns
}
// RegisterRoutes registers notification settings endpoints.
//
// Notification Settings Endpoints:
//
// GET /api/settings/notifications
//
// @Summary Get notification settings
// @Description Returns all notification settings including channel configuration, quiet hours, batching, and event type preferences.
// @Tags settings
// @Produce json
// @Success 200 {object} notificationSettingsResponse "Notification settings"
// @Router /api/settings/notifications [get]
//
// PUT /api/settings/notifications
//
// @Summary Update notification settings
// @Description Updates notification settings. Only the fields provided are modified.
// @Tags settings
// @Accept json
// @Produce json
// @Param request body notificationSettingsRequest true "Settings to update (partial)"
// @Success 200 {object} notificationSettingsResponse "Updated settings"
// @Failure 400 {object} map[string]string "Invalid request or validation error"
// @Failure 500 {object} map[string]string "Failed to update settings"
// @Router /api/settings/notifications [put]
//
// POST /api/notifications/test
//
// @Summary Send a test notification
// @Description Sends a test notification via the configured channel.
// @Tags notifications
// @Accept json
// @Produce json
// @Param request body testNotificationRequest true "Test notification parameters"
// @Success 200 {object} map[string]interface{} "Test result"
// @Failure 400 {object} map[string]string "Invalid request or no enabled channel"
// @Failure 500 {object} map[string]string "Failed to send notification"
// @Router /api/notifications/test [post]
func (h *NotificationSettingsHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/settings/notifications", h.handleGetSettings)
r.Put("/api/settings/notifications", h.handleUpdateSettings)
r.Post("/api/notifications/test", h.handleSendTest)
}
// notificationSettingsResponse is the response for notification settings requests.
type notificationSettingsResponse struct {
// Channel configuration
ChannelType string `json:"channel_type"` // "none", "ntfy", "pushover", "webhook"
ChannelConfig map[string]interface{} `json:"channel_config"` // Channel-specific credentials
// Quiet hours
QuietHoursEnabled bool `json:"quiet_hours_enabled"`
QuietHoursStart string `json:"quiet_hours_start"` // HH:MM format
QuietHoursEnd string `json:"quiet_hours_end"` // HH:MM format
QuietHoursDays int `json:"quiet_hours_days"` // Bitmask: 0x7F = all days (Sun=1, Mon=2, ..., Sat=64)
// Morning digest
MorningDigestEnabled bool `json:"morning_digest_enabled"`
MorningDigestTime string `json:"morning_digest_time"` // HH:MM format
// Smart batching
SmartBatchingEnabled bool `json:"smart_batching_enabled"` // Default: true
SmartBatchingWindow int `json:"smart_batching_window"` // Seconds, default: 30
// Event type preferences
EventTypes map[string]bool `json:"event_types"` // Event type -> enabled
}
// notificationSettingsRequest is the request body for updating notification settings.
// All fields are optional; only provided fields are updated.
type notificationSettingsRequest struct {
// Channel configuration
ChannelType *string `json:"channel_type,omitempty"`
ChannelConfig *map[string]interface{} `json:"channel_config,omitempty"`
// Quiet hours
QuietHoursEnabled *bool `json:"quiet_hours_enabled,omitempty"`
QuietHoursStart *string `json:"quiet_hours_start,omitempty"`
QuietHoursEnd *string `json:"quiet_hours_end,omitempty"`
QuietHoursDays *int `json:"quiet_hours_days,omitempty"`
// Morning digest
MorningDigestEnabled *bool `json:"morning_digest_enabled,omitempty"`
MorningDigestTime *string `json:"morning_digest_time,omitempty"`
// Smart batching
SmartBatchingEnabled *bool `json:"smart_batching_enabled,omitempty"`
SmartBatchingWindow *int `json:"smart_batching_window,omitempty"`
// Event type preferences
EventTypes *map[string]bool `json:"event_types,omitempty"`
}
// handleGetSettings handles GET /api/settings/notifications requests.
func (h *NotificationSettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.getSettings()
if err != nil {
log.Printf("[ERROR] Failed to get notification settings: %v", err)
writeJSONError(w, http.StatusInternalServerError, "failed to get notification settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
// handleUpdateSettings handles PUT /api/settings/notifications requests.
func (h *NotificationSettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var req notificationSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
// Validate and update settings
if err := h.updateSettings(&req); err != nil {
if ve, ok := err.(*NotificationValidationError); ok {
writeJSONError(w, http.StatusBadRequest, ve.Error())
} else {
writeJSONError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
}
return
}
// Return updated settings
settings, err := h.getSettings()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to get updated settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
// handleSendTest handles POST /api/notifications/test requests.
func (h *NotificationSettingsHandler) handleSendTest(w http.ResponseWriter, r *http.Request) {
var req testNotificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
// Get current settings to determine channel type
if req.ChannelType == "" {
settings, err := h.getSettings()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to get current settings")
return
}
req.ChannelType = settings.ChannelType
}
// Validate channel type
if req.ChannelType == "" || req.ChannelType == "none" {
writeJSONError(w, http.StatusBadRequest, "no notification channel configured")
return
}
// Set defaults
if req.Title == "" {
req.Title = "Spaxel Test Notification"
}
if req.Body == "" {
req.Body = "This is a test notification from Spaxel."
}
if req.Data == nil {
req.Data = make(map[string]interface{})
}
req.Data["test"] = true
// Send test notification
h.mu.RLock()
sender := h.notifyService
h.mu.RUnlock()
if sender == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "simulated",
"message": "Test notification simulated (no sender attached)",
})
return
}
if err := sender.Send(req.Title, req.Body, req.Data); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to send notification: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "sent",
"message": "Test notification sent successfully",
})
}
// getSettings retrieves notification settings from the database.
func (h *NotificationSettingsHandler) getSettings() (*notificationSettingsResponse, error) {
h.mu.RLock()
defer h.mu.RUnlock()
response := &notificationSettingsResponse{
// Set defaults
ChannelType: "none",
ChannelConfig: make(map[string]interface{}),
QuietHoursDays: 0x7F, // All days
MorningDigestTime: "07:00",
SmartBatchingEnabled: true,
SmartBatchingWindow: 30,
EventTypes: getDefaultEventTypes(),
}
// Get channel type
var channelTypeJSON string
err := h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_channel_type'`).Scan(&channelTypeJSON)
if err == nil {
json.Unmarshal([]byte(channelTypeJSON), &response.ChannelType) //nolint:errcheck
}
// Get channel config
var configJSON string
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_channel_config'`).Scan(&configJSON)
if err == nil {
var config map[string]interface{}
if err := json.Unmarshal([]byte(configJSON), &config); err == nil {
response.ChannelConfig = config
}
}
// Get quiet hours enabled
var quietEnabled int
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_quiet_hours_enabled'`).Scan(&quietEnabled)
if err == nil {
response.QuietHoursEnabled = quietEnabled != 0
}
// Get quiet hours start
var quietStartJSON string
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_quiet_hours_start'`).Scan(&quietStartJSON)
if err == nil {
json.Unmarshal([]byte(quietStartJSON), &response.QuietHoursStart) //nolint:errcheck
} else {
response.QuietHoursStart = "22:00"
}
// Get quiet hours end
var quietEndJSON string
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_quiet_hours_end'`).Scan(&quietEndJSON)
if err == nil {
json.Unmarshal([]byte(quietEndJSON), &response.QuietHoursEnd) //nolint:errcheck
} else {
response.QuietHoursEnd = "07:00"
}
// Get quiet hours days mask
var quietDays int
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_quiet_hours_days'`).Scan(&quietDays)
if err == nil {
response.QuietHoursDays = quietDays
}
// Get morning digest enabled
var morningDigest int
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_morning_digest_enabled'`).Scan(&morningDigest)
if err == nil {
response.MorningDigestEnabled = morningDigest != 0
} else {
response.MorningDigestEnabled = true // Default on
}
// Get morning digest time
var digestTimeJSON string
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_morning_digest_time'`).Scan(&digestTimeJSON)
if err == nil {
json.Unmarshal([]byte(digestTimeJSON), &response.MorningDigestTime) //nolint:errcheck
}
// Get smart batching enabled
var smartBatching int
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_smart_batching_enabled'`).Scan(&smartBatching)
if err == nil {
response.SmartBatchingEnabled = smartBatching != 0
}
// Get smart batching window
var batchWindow int
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_smart_batching_window'`).Scan(&batchWindow)
if err == nil {
response.SmartBatchingWindow = batchWindow
}
// Get event type preferences
var eventTypesJSON string
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'notification_event_types'`).Scan(&eventTypesJSON)
if err == nil {
var eventTypes map[string]bool
if err := json.Unmarshal([]byte(eventTypesJSON), &eventTypes); err == nil {
response.EventTypes = eventTypes
}
}
return response, nil
}
// updateSettings updates notification settings in the database.
func (h *NotificationSettingsHandler) updateSettings(req *notificationSettingsRequest) error {
h.mu.Lock()
defer h.mu.Unlock()
// Helper function to save a setting
saveSetting := func(key string, value interface{}) error {
valueJSON, err := json.Marshal(value)
if err != nil {
return err
}
_, err = h.db.Exec(`
INSERT INTO settings (key, value_json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value_json = ?, updated_at = ?
`, key, string(valueJSON), time.Now().UnixMilli(),
string(valueJSON), time.Now().UnixMilli())
return err
}
// Update channel type
if req.ChannelType != nil {
if err := validateChannelType(*req.ChannelType); err != nil {
return err
}
if err := saveSetting("notification_channel_type", *req.ChannelType); err != nil {
return err
}
}
// Update channel config
if req.ChannelConfig != nil {
if err := validateChannelConfig(*req.ChannelType, *req.ChannelConfig); err != nil {
return err
}
if err := saveSetting("notification_channel_config", *req.ChannelConfig); err != nil {
return err
}
}
// Update quiet hours
if req.QuietHoursEnabled != nil {
val := 0
if *req.QuietHoursEnabled {
val = 1
}
if err := saveSetting("notification_quiet_hours_enabled", val); err != nil {
return err
}
}
if req.QuietHoursStart != nil {
if err := validateTimeFormat(*req.QuietHoursStart); err != nil {
return &NotificationValidationError{Field: "quiet_hours_start", Reason: err.Error()}
}
if err := saveSetting("notification_quiet_hours_start", *req.QuietHoursStart); err != nil {
return err
}
}
if req.QuietHoursEnd != nil {
if err := validateTimeFormat(*req.QuietHoursEnd); err != nil {
return &NotificationValidationError{Field: "quiet_hours_end", Reason: err.Error()}
}
if err := saveSetting("notification_quiet_hours_end", *req.QuietHoursEnd); err != nil {
return err
}
}
if req.QuietHoursDays != nil {
if *req.QuietHoursDays < 0 || *req.QuietHoursDays > 0x7F {
return &NotificationValidationError{Field: "quiet_hours_days", Reason: "must be between 0 and 127"}
}
if err := saveSetting("notification_quiet_hours_days", *req.QuietHoursDays); err != nil {
return err
}
}
// Update morning digest
if req.MorningDigestEnabled != nil {
val := 0
if *req.MorningDigestEnabled {
val = 1
}
if err := saveSetting("notification_morning_digest_enabled", val); err != nil {
return err
}
}
if req.MorningDigestTime != nil {
if err := validateTimeFormat(*req.MorningDigestTime); err != nil {
return &NotificationValidationError{Field: "morning_digest_time", Reason: err.Error()}
}
if err := saveSetting("notification_morning_digest_time", *req.MorningDigestTime); err != nil {
return err
}
}
// Update smart batching
if req.SmartBatchingEnabled != nil {
val := 0
if *req.SmartBatchingEnabled {
val = 1
}
if err := saveSetting("notification_smart_batching_enabled", val); err != nil {
return err
}
}
if req.SmartBatchingWindow != nil {
if *req.SmartBatchingWindow < 5 || *req.SmartBatchingWindow > 300 {
return &NotificationValidationError{Field: "smart_batching_window", Reason: "must be between 5 and 300 seconds"}
}
if err := saveSetting("notification_smart_batching_window", *req.SmartBatchingWindow); err != nil {
return err
}
}
// Update event types
if req.EventTypes != nil {
if err := validateEventTypes(*req.EventTypes); err != nil {
return err
}
if err := saveSetting("notification_event_types", *req.EventTypes); err != nil {
return err
}
}
return nil
}
// validateChannelType validates the channel type.
func validateChannelType(channelType string) error {
switch channelType {
case "none", "ntfy", "pushover", "webhook":
return nil
default:
return &NotificationValidationError{Field: "channel_type", Reason: "must be one of: none, ntfy, pushover, webhook"}
}
}
// validateTimeFormat validates a time string in HH:MM format.
func validateTimeFormat(timeStr string) error {
if len(timeStr) != 5 {
return &NotificationValidationError{Reason: "invalid time format (must be HH:MM)"}
}
if timeStr[2] != ':' {
return &NotificationValidationError{Reason: "invalid time format (must be HH:MM)"}
}
hour := timeStr[0:2]
minute := timeStr[3:5]
for _, c := range hour {
if c < '0' || c > '9' {
return &NotificationValidationError{Reason: "invalid hour (must be 00-23)"}
}
}
for _, c := range minute {
if c < '0' || c > '9' {
return &NotificationValidationError{Reason: "invalid minute (must be 00-59)"}
}
}
// Validate numeric ranges
hourVal := int(hour[0]-'0')*10 + int(hour[1]-'0')
minuteVal := int(minute[0]-'0')*10 + int(minute[1]-'0')
if hourVal > 23 {
return &NotificationValidationError{Reason: "invalid hour (must be 00-23)"}
}
if minuteVal > 59 {
return &NotificationValidationError{Reason: "invalid minute (must be 00-59)"}
}
return nil
}
// validateEventTypes validates event type preferences.
func validateEventTypes(eventTypes map[string]bool) error {
validTypes := getDefaultEventTypes()
for eventType := range eventTypes {
if _, ok := validTypes[eventType]; !ok {
return &NotificationValidationError{Field: "event_types", Reason: "invalid event type: " + eventType}
}
}
return nil
}
// getDefaultEventTypes returns the default event type preferences (all enabled).
func getDefaultEventTypes() map[string]bool {
return map[string]bool{
"zone_enter": true,
"zone_leave": true,
"zone_vacant": true,
"fall_detected": true,
"fall_escalation": true,
"anomaly_alert": true,
"node_offline": true,
"sleep_summary": true,
}
}
// NotificationValidationError represents a validation error for a specific setting.
type NotificationValidationError struct {
Field string
Reason string
}
func (e *NotificationValidationError) Error() string {
if e.Field != "" {
return e.Field + ": " + e.Reason
}
return e.Reason
}