diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 87fd436..5508fa6 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -44,6 +44,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleReport(w, r) case r.Method == http.MethodPost && path == "/notify": h.handleNotify(w, r) + case r.Method == http.MethodPost && path == "/preferences": + h.handleSavePreferences(w, r) case r.Method == http.MethodGet && path == "/customers": h.handleCustomers(w, r) case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"): @@ -282,6 +284,44 @@ func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"status":"ok","sent":true}`)) } +// handleSavePreferences stores notification preferences pushed from a customer controller. +func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) { + // Same bearer token auth as /report and /notify + if h.apiKey != "" { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + var payload struct { + CustomerID string `json:"customer_id"` + Email string `json:"email"` + EnabledEvents []string `json:"enabled_events"` + } + if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" { + http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest) + return + } + + if err := h.store.SaveNotificationPrefs(payload.CustomerID, payload.Email, payload.EnabledEvents); err != nil { + h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, payload.Email, payload.EnabledEvents) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) +} + // sendResendEmail sends an email via the Resend HTTP API. func (h *Handler) sendResendEmail(to, subject, textBody string) error { payload := map[string]interface{}{ diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index f95917a..bfc71f5 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -151,6 +151,43 @@ func (s *Store) LogNotification(customerID, eventType, severity, message, status return err } +// NotificationLogEntry represents a single notification log record. +type NotificationLogEntry struct { + EventType string + Severity string + Message string + Status string // "sent", "skipped", "failed" + ErrorMessage string + CreatedAt time.Time +} + +// GetRecentNotifications returns the most recent notification log entries for a customer. +func (s *Store) GetRecentNotifications(customerID string, limit int) ([]NotificationLogEntry, error) { + rows, err := s.db.Query(` + SELECT event_type, severity, message, status, COALESCE(error_message, ''), created_at + FROM notification_log + WHERE customer_id = ? + ORDER BY created_at DESC + LIMIT ?`, customerID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []NotificationLogEntry + for rows.Next() { + var e NotificationLogEntry + var createdAt, errorMsg string + if err := rows.Scan(&e.EventType, &e.Severity, &e.Message, &e.Status, &errorMsg, &createdAt); err != nil { + return nil, err + } + e.CreatedAt = parseSQLiteTime(createdAt) + e.ErrorMessage = errorMsg + entries = append(entries, e) + } + return entries, rows.Err() +} + // SaveReport stores a new report. The reportJSON should be the raw JSON payload. func (s *Store) SaveReport(customerID string, reportJSON []byte) error { // Parse denormalized fields from the JSON diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 2428276..d1da221 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -30,7 +30,8 @@ func New(store *store.Store, passwordHash string, staleThreshold time.Duration, "statusColor": statusColor, "statusIcon": statusIcon, "formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) }, - "json": func(v interface{}) template.JS { + "joinStrings": func(s []string, sep string) string { return strings.Join(s, sep) }, + "json": func(v interface{}) template.JS { b, _ := json.Marshal(v) return template.JS(b) }, @@ -184,11 +185,17 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu // Get history (last 24h) history, _ := s.store.GetCustomerHistory(customerID, 24*time.Hour) + // Get notification preferences and recent log + notifPrefs, _ := s.store.GetNotificationPrefs(customerID) + recentNotifs, _ := s.store.GetRecentNotifications(customerID, 10) + type detailData struct { - Customer *store.CustomerSummary - Report map[string]interface{} - History []store.CustomerSummary - OverallStatus string + Customer *store.CustomerSummary + Report map[string]interface{} + History []store.CustomerSummary + OverallStatus string + NotifPrefs *store.NotificationPrefs + RecentNotifications []store.NotificationLogEntry } overallStatus := "ok" @@ -201,10 +208,12 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu } data := detailData{ - Customer: customer, - Report: report, - History: history, - OverallStatus: overallStatus, + Customer: customer, + Report: report, + History: history, + OverallStatus: overallStatus, + NotifPrefs: notifPrefs, + RecentNotifications: recentNotifs, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/hub/internal/web/templates/customer.html b/hub/internal/web/templates/customer.html index 2118bf6..57d756f 100644 --- a/hub/internal/web/templates/customer.html +++ b/hub/internal/web/templates/customer.html @@ -155,6 +155,46 @@ {{end}} + +
+

Notifications

+
+
+ Email + {{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}} +
+ {{if .NotifPrefs}} +
+ Events + {{if .NotifPrefs.EnabledEvents}}{{joinStrings .NotifPrefs.EnabledEvents ", "}}{{else}}None{{end}} +
+ {{end}} +
+ {{if .RecentNotifications}} +

Recent (last 10)

+ + + + + + + + + + + {{range .RecentNotifications}} + + + + + + + {{end}} + +
TimeEventStatusMessage
{{.CreatedAt.Format "Jan 02 15:04"}}{{.EventType}}{{.Status}}{{.Message}}
+ {{end}} +
+ {{if .History}}