Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
580 lines
18 KiB
Go
580 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() //nolint:errcheck
|
|
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() //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
// Archive runs the archive job to move old events to the archive table.
|
|
func (e *EventsHandler) Archive(_ interface{}) {
|
|
_ = events.RunArchiveJob(e.db) //nolint:errcheck // errors logged internally
|
|
}
|
|
|
|
// 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() //nolint:errcheck
|
|
|
|
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",
|
|
})
|
|
}
|