- Added comprehensive integration tests in test/acceptance/ covering all 6 acceptance scenarios from plan.md - AS-1: First-time setup in under 5 minutes - verifies PIN setup and node auto-discovery - AS-2: Person detected while walking - verifies blob detection during walker simulation - AS-3: Fall alert fires correctly - verifies fall detection with webhook integration - AS-4: BLE identity resolves to person name - verifies BLE device registration and identity matching - AS-5: OTA update succeeds / rollback on bad firmware - verifies OTA workflow and rollback - AS-6: Replay shows recorded history - verifies replay session creation, seeking, and playback Tests use spaxel-sim CLI as the test harness and verify: - API endpoint responses (/api/auth/setup, /api/nodes, /api/blobs, /api/events, /api/ble/devices, /api/replay/*) - Detection accuracy thresholds (>60% blob presence during walking) - Alert generation and webhook delivery - Firmware version updates and rollback behavior - Replay session lifecycle management All tests skip by default unless ACCEPTANCE_TEST=1 or SPAXEL_INTEGRATION_TEST=1 is set. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
349 lines
10 KiB
Go
349 lines
10 KiB
Go
// Package api provides REST API handlers for morning briefings.
|
|
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
_ "modernc.org/sqlite"
|
|
"github.com/spaxel/mothership/internal/briefing"
|
|
)
|
|
|
|
// BriefingHandler manages morning briefing REST endpoints.
|
|
type BriefingHandler struct {
|
|
generator *briefing.Generator
|
|
db *sql.DB
|
|
notifyService briefing.NotifyService
|
|
zoneProvider briefing.ZoneProvider
|
|
personProvider briefing.PersonProvider
|
|
predictionProvider briefing.PredictionProvider
|
|
healthProvider briefing.HealthProvider
|
|
}
|
|
|
|
// NewBriefingHandler creates a new briefing handler.
|
|
func NewBriefingHandler(dataDir string) (*BriefingHandler, error) {
|
|
gen, err := briefing.NewGenerator(dataDir + "/spaxel.db")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Open database connection for settings persistence
|
|
db, err := sql.Open("sqlite", dataDir+"/spaxel.db")
|
|
if err != nil {
|
|
gen.Close() //nolint:errcheck
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
|
|
return &BriefingHandler{
|
|
generator: gen,
|
|
db: db,
|
|
}, nil
|
|
}
|
|
|
|
// SetProviders sets the provider interfaces for briefing generation.
|
|
func (h *BriefingHandler) SetProviders(z briefing.ZoneProvider, p briefing.PersonProvider, pr briefing.PredictionProvider, hp briefing.HealthProvider) {
|
|
h.zoneProvider = z
|
|
h.personProvider = p
|
|
h.predictionProvider = pr
|
|
h.healthProvider = hp
|
|
h.generator.SetProviders(z, p, pr, hp)
|
|
}
|
|
|
|
// Close closes the generator and database connection.
|
|
func (h *BriefingHandler) Close() error {
|
|
var firstErr error
|
|
if h.generator != nil {
|
|
if err := h.generator.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
if h.db != nil {
|
|
if err := h.db.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
// SetNotifyService sets the notification service for sending test notifications.
|
|
func (h *BriefingHandler) SetNotifyService(notifySvc briefing.NotifyService) {
|
|
h.notifyService = notifySvc
|
|
}
|
|
|
|
// RegisterRoutes registers the briefing API routes.
|
|
func (h *BriefingHandler) RegisterRoutes(r chi.Router) {
|
|
r.Get("/api/briefing", h.handleGetBriefing)
|
|
r.Get("/api/briefing/today", h.handleGetTodayBriefing)
|
|
r.Get("/api/briefing/{date}", h.handleGetBriefingByDate)
|
|
r.Post("/api/briefing/generate", h.handleGenerateBriefing)
|
|
r.Get("/api/briefing/latest", h.handleGetLatestBriefing)
|
|
r.Post("/api/briefing/{id}/acknowledge", h.handleAcknowledgeBriefing)
|
|
r.Get("/api/briefing/settings", h.handleGetSettings)
|
|
r.Patch("/api/briefing/settings", h.handleUpdateSettings)
|
|
r.Post("/api/briefing/test", h.handleTestNotification)
|
|
}
|
|
|
|
// handleGetBriefing returns the briefing for today or a specific date.
|
|
func (h *BriefingHandler) handleGetBriefing(w http.ResponseWriter, r *http.Request) {
|
|
date := r.URL.Query().Get("date")
|
|
if date == "" {
|
|
date = time.Now().Format("2006-01-02")
|
|
}
|
|
|
|
person := r.URL.Query().Get("person")
|
|
|
|
b, err := h.generator.Get(date, person)
|
|
if err != nil {
|
|
http.Error(w, "Briefing not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, b)
|
|
}
|
|
|
|
// handleGetBriefingByDate returns the briefing for a specific date (RESTful path parameter).
|
|
func (h *BriefingHandler) handleGetBriefingByDate(w http.ResponseWriter, r *http.Request) {
|
|
date := chi.URLParam(r, "date")
|
|
if date == "" {
|
|
http.Error(w, "Date parameter required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
person := r.URL.Query().Get("person")
|
|
|
|
b, err := h.generator.Get(date, person)
|
|
if err != nil {
|
|
http.Error(w, "Briefing not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, b)
|
|
}
|
|
|
|
// handleGenerateBriefing generates a new briefing for the given date.
|
|
func (h *BriefingHandler) handleGenerateBriefing(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Date string `json:"date"`
|
|
Person string `json:"person"`
|
|
}
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Date == "" {
|
|
req.Date = time.Now().Format("2006-01-02")
|
|
}
|
|
|
|
b, err := h.generator.Generate(req.Date, req.Person)
|
|
if err != nil {
|
|
log.Printf("[ERROR] Failed to generate briefing: %v", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := h.generator.Save(b); err != nil {
|
|
log.Printf("[ERROR] Failed to save briefing: %v", err)
|
|
// Still return the briefing even if save failed
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, b)
|
|
}
|
|
|
|
// handleGetLatestBriefing returns the most recent briefing.
|
|
func (h *BriefingHandler) handleGetLatestBriefing(w http.ResponseWriter, r *http.Request) {
|
|
b, err := h.generator.GetLatest()
|
|
if err != nil {
|
|
http.Error(w, "No briefing found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, b)
|
|
}
|
|
|
|
// handleGetSettings returns briefing settings.
|
|
func (h *BriefingHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
// Try to load settings from database
|
|
var settingsJSON sql.NullString
|
|
err := h.db.QueryRow("SELECT value_json FROM settings WHERE key = 'briefing_config'").Scan(&settingsJSON)
|
|
|
|
settings := map[string]interface{}{
|
|
"enabled": true,
|
|
"time": "07:00",
|
|
"push_notification": true,
|
|
"auto_generate": true,
|
|
}
|
|
|
|
if err == nil && settingsJSON.Valid {
|
|
var savedConfig map[string]interface{}
|
|
if err := json.Unmarshal([]byte(settingsJSON.String), &savedConfig); err == nil {
|
|
if enabled, ok := savedConfig["enabled"].(bool); ok {
|
|
settings["enabled"] = enabled
|
|
}
|
|
if timeStr, ok := savedConfig["time"].(string); ok {
|
|
settings["time"] = timeStr
|
|
}
|
|
if push, ok := savedConfig["push_notification"].(bool); ok {
|
|
settings["push_notification"] = push
|
|
}
|
|
if auto, ok := savedConfig["auto_generate"].(bool); ok {
|
|
settings["auto_generate"] = auto
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
// handleUpdateSettings updates briefing settings.
|
|
func (h *BriefingHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|
var settings map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate settings
|
|
if timeStr, ok := settings["time"].(string); ok {
|
|
// Validate time format (HH:MM)
|
|
var h, m int
|
|
_, err := fmt.Sscanf(timeStr, "%d:%d", &h, &m)
|
|
if err != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
|
http.Error(w, "Invalid time format, use HH:MM", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Save to database settings table
|
|
settingsJSON, err := json.Marshal(settings)
|
|
if err != nil {
|
|
log.Printf("[ERROR] Failed to marshal briefing settings: %v", err)
|
|
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
_, err = h.db.Exec(`
|
|
INSERT OR REPLACE INTO settings (key, value_json, updated_at)
|
|
VALUES ('briefing_config', ?, strftime('%s', 'now') * 1000)
|
|
`, string(settingsJSON))
|
|
if err != nil {
|
|
log.Printf("[ERROR] Failed to save briefing settings: %v", err)
|
|
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Printf("[INFO] Briefing settings updated: %+v", settings)
|
|
|
|
// Update scheduler config if available
|
|
// Note: The scheduler will pick up the new config on next check
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleTestNotification sends a test briefing notification.
|
|
func (h *BriefingHandler) handleTestNotification(w http.ResponseWriter, r *http.Request) {
|
|
// Generate a test briefing for today
|
|
date := time.Now().Format("2006-01-02")
|
|
b, err := h.generator.Generate(date, "")
|
|
if err != nil {
|
|
log.Printf("[ERROR] Failed to generate test briefing: %v", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Send via notification service if available
|
|
if h.notifyService != nil {
|
|
notif := briefing.Notification{
|
|
Title: "Morning Briefing (Test)",
|
|
Body: b.Content,
|
|
Priority: 1,
|
|
Tags: []string{"briefing", "test"},
|
|
Timestamp: time.Now(),
|
|
}
|
|
if err := h.notifyService.Send(notif); err != nil {
|
|
log.Printf("[ERROR] Failed to send test notification: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
"briefing": b,
|
|
})
|
|
return
|
|
}
|
|
log.Printf("[INFO] Test briefing notification sent")
|
|
} else {
|
|
log.Printf("[INFO] Test briefing notification (no notify service): %s", b.Content)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "sent",
|
|
"briefing": b,
|
|
})
|
|
}
|
|
|
|
// handleGetTodayBriefing returns today's briefing, generating if needed.
|
|
func (h *BriefingHandler) handleGetTodayBriefing(w http.ResponseWriter, r *http.Request) {
|
|
today := time.Now().Format("2006-01-02")
|
|
person := r.URL.Query().Get("person")
|
|
|
|
// First try to get existing briefing
|
|
b, err := h.generator.Get(today, person)
|
|
if err == nil {
|
|
// Check if it's marked as delivered
|
|
if !b.Delivered {
|
|
// Mark as delivered on first fetch
|
|
if err := h.generator.MarkDelivered(b.ID); err != nil {
|
|
log.Printf("[WARN] Failed to mark briefing as delivered: %v", err)
|
|
}
|
|
b.Delivered = true
|
|
}
|
|
writeJSON(w, http.StatusOK, b)
|
|
return
|
|
}
|
|
|
|
// No briefing exists, generate one
|
|
b, err = h.generator.Generate(today, person)
|
|
if err != nil {
|
|
log.Printf("[ERROR] Failed to generate today's briefing: %v", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Save the new briefing
|
|
if err := h.generator.Save(b); err != nil {
|
|
log.Printf("[ERROR] Failed to save briefing: %v", err)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, b)
|
|
}
|
|
|
|
// handleAcknowledgeBriefing marks a briefing as acknowledged by the user.
|
|
func (h *BriefingHandler) handleAcknowledgeBriefing(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
http.Error(w, "Briefing ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Mark as acknowledged
|
|
if err := h.generator.MarkAcknowledged(id); err != nil {
|
|
log.Printf("[ERROR] Failed to acknowledge briefing: %v", err)
|
|
http.Error(w, "Failed to acknowledge briefing", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Printf("[INFO] Briefing %s acknowledged", id)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "acknowledged"})
|
|
}
|
|
|
|
// GetGenerator returns the underlying briefing generator.
|
|
func (h *BriefingHandler) GetGenerator() *briefing.Generator {
|
|
return h.generator
|
|
}
|