spaxel/mothership/internal/api/events.go
jedarden efea321f19 feat(timeline): add search and filter to event timeline
Add server-side types filter (comma-separated) for category-based filtering,
fuzzy text search with FTS5 fallback on Enter, and improved client-side
filtering with character-sequence matching. Category checkboxes now send
types to server for efficient loading. Includes table-driven tests for types
filter, pagination, and combined filter scenarios.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:07:08 -04:00

581 lines
18 KiB
Go

// Package api provides REST API handlers for Spaxel events timeline.
package api
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
"github.com/spaxel/mothership/internal/events"
)
const (
eventsDefaultLimit = 50
eventsMaxLimit = 500
)
// EventsHandler manages the events timeline.
type EventsHandler struct {
mu sync.RWMutex
db *sql.DB
hub DashboardHub
ownsDB bool
feedbackHandler any // FeedbackHandler for POST /api/events/{id}/feedback
}
// DashboardHub is the interface for broadcasting to dashboard clients.
type DashboardHub interface {
BroadcastEventFromDB(id int64, timestamp int64, eventType, zone, person string, blobID int, detailJSON, severity string)
}
// Event represents a timeline event.
type Event struct {
ID int64 `json:"id"`
Timestamp int64 `json:"timestamp_ms"`
Type string `json:"type"`
Zone string `json:"zone,omitempty"`
Person string `json:"person,omitempty"`
BlobID int `json:"blob_id,omitempty"`
DetailJSON string `json:"detail_json,omitempty"`
Severity string `json:"severity"`
}
// LogEvent logs a new event to the database and broadcasts it.
func (h *EventsHandler) LogEvent(eventType string, timestamp time.Time, zone, person string, blobID int, detailJSON string, severity string) error {
h.mu.Lock()
defer h.mu.Unlock()
ts := timestamp.UnixNano() / 1e6
if severity == "" {
severity = "info"
}
result, err := h.db.Exec(`
INSERT INTO events (timestamp_ms, type, zone, person, blob_id, detail_json, severity)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, ts, eventType, zone, person, blobID, detailJSON, severity)
if err != nil {
return err
}
id, _ := result.LastInsertId()
// Broadcast to dashboard clients
if h.hub != nil {
h.hub.BroadcastEventFromDB(id, ts, eventType, zone, person, blobID, detailJSON, severity)
}
return nil
}
// SetHub sets the dashboard hub for broadcasting events.
func (e *EventsHandler) SetHub(hub DashboardHub) {
e.mu.Lock()
defer e.mu.Unlock()
e.hub = hub
}
// SetFeedbackHandler sets the feedback handler for event feedback endpoints.
func (e *EventsHandler) SetFeedbackHandler(handler any) {
e.mu.Lock()
defer e.mu.Unlock()
e.feedbackHandler = handler
}
// NewEventsHandler creates a new events handler backed by a SQLite file at dbPath.
// It opens the database, creates the schema, and takes ownership of the connection.
// Use Close() to release resources.
func NewEventsHandler(dbPath string) (*EventsHandler, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open events db: %w", err)
}
if err := createEventsSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("init events schema: %w", err)
}
log.Printf("[INFO] Events handler initialized (own DB: %s)", dbPath)
return &EventsHandler{db: db, ownsDB: true}, nil
}
// NewEventsHandlerFromDB creates a new events handler using an existing database connection.
// The events table schema must already exist (created by migrations 001 and 011).
func NewEventsHandlerFromDB(db *sql.DB) *EventsHandler {
log.Printf("[INFO] Events handler initialized (shared DB)")
return &EventsHandler{db: db}
}
// Close releases resources. If the handler owns the DB connection, it closes it.
func (e *EventsHandler) Close() {
if e.ownsDB {
e.db.Close()
}
}
// Archive runs the archive job to move old events to the archive table.
func (e *EventsHandler) Archive(_ interface{}) {
events.RunArchiveJob(e.db)
}
// createEventsSchema creates the events, events_archive, and FTS5 tables.
func createEventsSchema(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp_ms INTEGER NOT NULL,
type TEXT NOT NULL,
zone TEXT,
person TEXT,
blob_id INTEGER,
detail_json TEXT,
severity TEXT NOT NULL DEFAULT 'info'
);
CREATE INDEX IF NOT EXISTS idx_events_time ON events(timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_events_zone ON events(zone, timestamp_ms DESC);
CREATE INDEX IF NOT EXISTS idx_events_person ON events(person, timestamp_ms DESC);
CREATE TABLE IF NOT EXISTS events_archive (
id INTEGER PRIMARY KEY,
timestamp_ms INTEGER NOT NULL,
type TEXT NOT NULL,
zone TEXT,
person TEXT,
blob_id INTEGER,
detail_json TEXT,
severity TEXT NOT NULL DEFAULT 'info'
);
CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
type, zone, person, detail_json,
content='events', content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS events_fts_insert AFTER INSERT ON events BEGIN
INSERT INTO events_fts(rowid, type, zone, person, detail_json)
VALUES (new.id, new.type, new.zone, new.person, new.detail_json);
END;
CREATE TRIGGER IF NOT EXISTS events_fts_delete AFTER DELETE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, type, zone, person, detail_json)
VALUES ('delete', old.id, old.type, old.zone, old.person, old.detail_json);
END;
CREATE TRIGGER IF NOT EXISTS events_fts_update AFTER UPDATE ON events BEGIN
INSERT INTO events_fts(events_fts, rowid, type, zone, person, detail_json)
VALUES ('delete', old.id, old.type, old.zone, old.person, old.detail_json);
INSERT INTO events_fts(rowid, type, zone, person, detail_json)
VALUES (new.id, new.type, new.zone, new.person, new.detail_json);
END;
`
_, err := db.Exec(schema)
return err
}
// isValidEventType checks whether the event type string is a known type.
func isValidEventType(t string) bool {
switch t {
case "detection", "zone_entry", "zone_exit", "portal_crossing",
"trigger_fired", "fall_alert", "FallDetected", "anomaly", "AnomalyDetected", "security_alert",
"node_online", "node_offline", "ota_update", "baseline_changed",
"system", "learning_milestone", "sleep_session_end", "ZoneTransition",
"presence_transition", "stationary_detected", "anomaly_learned", "sleep_session_start":
return true
}
return false
}
// RegisterRoutes registers events endpoints.
//
// GET /api/events — paginated event list with FTS5 search and keyset cursor pagination.
//
// Query params: limit (default 50, max 500), before (timestamp_ms cursor),
// since (ISO8601), until (ISO8601), type (single type filter),
// types (comma-separated type list for category filtering),
// zone, zone_id, person, person_id, q (FTS5 query), mode (expert|simple).
//
// GET /api/events/{id} — single event by ID.
//
// POST /api/events/{id}/feedback — submit feedback for an event.
func (e *EventsHandler) RegisterRoutes(r chi.Router) {
r.Get("/api/events", e.listEvents)
r.Get("/api/events/{id}", e.getEvent)
r.Post("/api/events/{id}/feedback", e.postEventFeedback)
}
// eventsResponse is the JSON response for GET /api/events.
type eventsResponse struct {
Events []*Event `json:"events"`
Cursor string `json:"cursor,omitempty"`
HasMore bool `json:"has_more"`
TotalFiltered int `json:"total_filtered"`
}
// prepareFTSQuery appends a trailing * for prefix matching if the query
// doesn't already end with a FTS5 operator character. This enables
// partial word matching (e.g., "kit" matches "kitchen").
func prepareFTSQuery(q string) string {
q = strings.TrimSpace(q)
if q == "" {
return q
}
// If the query already ends with a FTS5 special character or operator, leave it alone.
last := q[len(q)-1]
if last == '*' || last == '"' || last == ')' {
return q
}
// For simple terms (no operators), append * for prefix matching.
// If the query contains FTS5 operators (AND, OR, NOT, NEAR), append * to each
// simple token instead.
if strings.Contains(q, " AND ") || strings.Contains(q, " OR ") ||
strings.Contains(q, " NOT ") || strings.Contains(q, " NEAR ") {
// Has operators — append * to each token that isn't an operator or quoted phrase.
parts := strings.Fields(q)
for i, p := range parts {
if p == "AND" || p == "OR" || p == "NOT" || p == "NEAR" {
continue
}
if (strings.HasPrefix(p, `"`) && strings.HasSuffix(p, `"`)) || p == "(" || p == ")" {
continue
}
parts[i] = p + "*"
}
return strings.Join(parts, " ")
}
// Simple single-term query — just append * for prefix matching.
return q + "*"
}
func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
// Parse limit
limit := eventsDefaultLimit
if s := r.URL.Query().Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 {
limit = n
}
}
if limit > eventsMaxLimit {
limit = eventsMaxLimit
}
// Parse before cursor (timestamp_ms as string)
var beforeTS int64
if s := r.URL.Query().Get("before"); s != "" {
beforeTS, _ = strconv.ParseInt(s, 10, 64)
}
// Parse filters
q := r.URL.Query().Get("q")
eventType := r.URL.Query().Get("type")
typesStr := r.URL.Query().Get("types") // comma-separated list of event types
zone := r.URL.Query().Get("zone")
zoneID := r.URL.Query().Get("zone_id")
if zoneID != "" {
zone = zoneID // zone_id takes precedence over zone
}
person := r.URL.Query().Get("person")
personID := r.URL.Query().Get("person_id")
if personID != "" {
person = personID // person_id takes precedence over person
}
afterStr := r.URL.Query().Get("after")
sinceStr := r.URL.Query().Get("since") // Alias for after
untilStr := r.URL.Query().Get("until") // Upper bound timestamp
mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple)
// Parse and validate types list (comma-separated)
var typesFilter []string
if typesStr != "" {
for _, t := range strings.Split(typesStr, ",") {
t = strings.TrimSpace(t)
if t == "" {
continue
}
if !isValidEventType(t) {
writeJSONError(w, http.StatusBadRequest, "invalid event type: "+t)
return
}
typesFilter = append(typesFilter, t)
}
}
// Validate event type
if eventType != "" && !isValidEventType(eventType) {
writeJSONError(w, http.StatusBadRequest, "invalid event type")
return
}
// Validate after/since timestamp (prefer since if both provided)
var afterTS int64
timeStr := afterStr
if sinceStr != "" {
timeStr = sinceStr
}
if timeStr != "" {
t, err := time.Parse(time.RFC3339, timeStr)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid since/after timestamp")
return
}
afterTS = t.UnixNano() / 1e6
}
// Validate until timestamp (upper bound)
var untilTS int64
if untilStr != "" {
t, err := time.Parse(time.RFC3339, untilStr)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid until timestamp")
return
}
untilTS = t.UnixNano() / 1e6
}
// Person-relevant event types for simple mode
// Simple mode shows only person-relevant events: zone_entry, zone_exit, ZoneTransition, portal_crossing, fall_alert, FallDetected, anomaly, AnomalyDetected, 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{
"zone_entry": true,
"zone_exit": true,
"ZoneTransition": true,
"portal_crossing": true,
"fall_alert": true,
"FallDetected": true,
"anomaly": true,
"AnomalyDetected": true,
"security_alert": true,
"sleep_session_end": true,
}
// Default to expert mode; switch to simple mode only when explicitly requested
isSimpleMode := mode == "simple"
// Prepare FTS5 query with prefix matching
if q != "" {
q = prepareFTSQuery(q)
}
// Determine query mode: FTS5 or regular
useFTS := q != ""
p := "" // column prefix for FTS JOIN queries
if useFTS {
p = "e."
}
// Build SELECT columns and FROM clause
selectCols := p + "id, " + p + "timestamp_ms, " + p + "type, " + p + "zone, " +
p + "person, " + p + "blob_id, " + p + "detail_json, " + p + "severity"
var fromTable, baseWhere string
var baseArgs []interface{}
if useFTS {
fromTable = "events e JOIN events_fts ft ON e.id = ft.rowid"
baseWhere = "events_fts MATCH ?"
baseArgs = []interface{}{q}
} else {
fromTable = "events"
baseWhere = "1=1"
baseArgs = []interface{}{}
}
// Build WHERE clause with filters
whereSQL := baseWhere
whereArgs := append([]interface{}{}, baseArgs...)
if eventType != "" {
whereSQL += " AND " + p + "type = ?"
whereArgs = append(whereArgs, eventType)
} else if len(typesFilter) > 0 {
// Multiple type filter (from category checkboxes)
ph := make([]string, len(typesFilter))
for i := range ph {
ph[i] = "?"
}
whereSQL += " AND " + p + "type IN (" + strings.Join(ph, ", ") + ")"
for _, t := range typesFilter {
whereArgs = append(whereArgs, t)
}
} else if isSimpleMode {
// 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 = ?"
whereArgs = append(whereArgs, zone)
}
if person != "" {
whereSQL += " AND " + p + "person = ?"
whereArgs = append(whereArgs, person)
}
if afterTS > 0 {
whereSQL += " AND " + p + "timestamp_ms >= ?"
whereArgs = append(whereArgs, afterTS)
}
if untilTS > 0 {
// Use < untilTS+1000 to include the entire second when the until timestamp
// is in second precision (RFC3339 format truncates sub-seconds).
whereSQL += " AND " + p + "timestamp_ms < ?"
whereArgs = append(whereArgs, untilTS+1000)
}
// COUNT for total_filtered
countSQL := "SELECT COUNT(*) FROM " + fromTable + " WHERE " + whereSQL
var totalFiltered int
if err := e.db.QueryRow(countSQL, whereArgs...).Scan(&totalFiltered); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to count events")
return
}
// Data query: add before cursor + ordering + limit
dataWhere := whereSQL
dataArgs := append([]interface{}{}, whereArgs...)
if beforeTS > 0 {
dataWhere += " AND " + p + "timestamp_ms < ?"
dataArgs = append(dataArgs, beforeTS)
}
// untilTS is already included in the base WHERE clause via whereArgs
dataSQL := "SELECT " + selectCols + " FROM " + fromTable +
" WHERE " + dataWhere +
" ORDER BY " + p + "timestamp_ms DESC, " + p + "id DESC" +
" LIMIT ?"
dataArgs = append(dataArgs, limit+1)
rows, err := e.db.Query(dataSQL, dataArgs...)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to query events")
return
}
defer rows.Close()
events := make([]*Event, 0, limit)
for rows.Next() {
var ev Event
if err := rows.Scan(&ev.ID, &ev.Timestamp, &ev.Type, &ev.Zone,
&ev.Person, &ev.BlobID, &ev.DetailJSON, &ev.Severity); err != nil {
continue
}
events = append(events, &ev)
}
hasMore := len(events) > limit
if hasMore {
events = events[:limit]
}
cursor := ""
if hasMore && len(events) > 0 {
cursor = strconv.FormatInt(events[len(events)-1].Timestamp, 10)
}
writeJSON(w, http.StatusOK, eventsResponse{
Events: events,
Cursor: cursor,
HasMore: hasMore,
TotalFiltered: totalFiltered,
})
}
func (e *EventsHandler) getEvent(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid event id")
return
}
var event Event
err = e.db.QueryRow(`
SELECT id, timestamp_ms, type, zone, person, blob_id, detail_json, severity
FROM events
WHERE id = ?
`, id).Scan(&event.ID, &event.Timestamp, &event.Type, &event.Zone,
&event.Person, &event.BlobID, &event.DetailJSON, &event.Severity)
if err == sql.ErrNoRows {
writeJSONError(w, http.StatusNotFound, "event not found")
return
} else if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to query event")
return
}
writeJSON(w, http.StatusOK, event)
}
// postEventFeedback handles POST /api/events/{id}/feedback
// It delegates to the feedback module after validating the event exists.
func (e *EventsHandler) postEventFeedback(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
eventID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid event id")
return
}
// Verify the event exists
var exists bool
err = e.db.QueryRow("SELECT EXISTS(SELECT 1 FROM events WHERE id = ?)", eventID).Scan(&exists)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to query event")
return
}
if !exists {
writeJSONError(w, http.StatusNotFound, "event not found")
return
}
// Decode request body
var req FeedbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
// Set the event ID from the URL path
req.EventID = eventID
// Validate feedback type
if req.Type != "correct" && req.Type != "incorrect" && req.Type != "missed" {
writeJSONError(w, http.StatusBadRequest, "invalid feedback type: must be 'correct', 'incorrect', or 'missed'")
return
}
// Delegate to feedback handler if available
if e.feedbackHandler != nil {
// Use the feedback handler to process the request
type submitter interface {
SubmitFeedback(w http.ResponseWriter, r *http.Request, req FeedbackRequest)
}
if fh, ok := e.feedbackHandler.(submitter); ok {
fh.SubmitFeedback(w, r, req)
return
}
}
// Fallback: log a feedback event
_ = e.LogEvent("feedback", time.Now(), "", "", 0,
fmt.Sprintf(`{"event_id":%d,"type":"%s","blob_id":%d}`, eventID, req.Type, req.BlobID), "info")
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"message": "Feedback recorded",
})
}