From e531516cfa81f82ecd0fbda80edb84d2d545953e Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 16 Feb 2026 19:29:55 +0100 Subject: [PATCH] Hub: add POST /api/v1/notify endpoint for customer notifications - New notification relay endpoint: receives events from customer controllers, looks up customer email preferences, sends via Resend HTTP API - New tables: customer_notifications (per-customer email + event prefs), notification_log (audit trail for all notification attempts) - Hungarian email template with severity, event type, timestamp - Config: notifications.resend_api_key + notifications.from_email - Test events always pass event-type filter Co-Authored-By: Claude Opus 4.6 --- hub/cmd/hub/main.go | 9 +- hub/configs/hub.yaml.example | 5 + hub/internal/api/handler.go | 189 +++++++++++++++++++++++++++++++++-- hub/internal/store/store.go | 78 +++++++++++++++ 4 files changed, 273 insertions(+), 8 deletions(-) diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index b312872..c29379f 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -31,6 +31,10 @@ type Config struct { API struct { ReportAPIKey string `yaml:"report_api_key"` } `yaml:"api"` + Notifications struct { + ResendAPIKey string `yaml:"resend_api_key"` + FromEmail string `yaml:"from_email"` + } `yaml:"notifications"` Retention struct { MaxDays int `yaml:"max_days"` PruneSchedule string `yaml:"prune_schedule"` @@ -79,7 +83,7 @@ func main() { } // Initialize handlers - apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, logger) + apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, logger) webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger) // Build HTTP mux @@ -177,6 +181,9 @@ func loadConfig(path string, logger *log.Logger) *Config { if cfg.Alerting.StaleThreshold == "" { cfg.Alerting.StaleThreshold = "30m" } + if cfg.Notifications.FromEmail == "" { + cfg.Notifications.FromEmail = "monitoring@felhom.eu" + } return cfg } diff --git a/hub/configs/hub.yaml.example b/hub/configs/hub.yaml.example index c9e8a8b..755c2c3 100644 --- a/hub/configs/hub.yaml.example +++ b/hub/configs/hub.yaml.example @@ -17,6 +17,11 @@ api: # Bearer token required for report ingest (POST /api/v1/report) report_api_key: "" +# --- Notifications --- +notifications: + resend_api_key: "" # Resend.com API key for sending notification emails + from_email: "monitoring@felhom.eu" # Sender address for notification emails + # --- Data retention --- retention: max_days: 90 # Keep 90 days of report history diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index b382287..87fd436 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -1,7 +1,9 @@ package api import ( + "bytes" "encoding/json" + "fmt" "io" "log" "net/http" @@ -13,17 +15,23 @@ import ( // Handler handles API endpoints for report ingest and customer queries. type Handler struct { - store *store.Store - apiKey string - logger *log.Logger + store *store.Store + apiKey string + resendAPIKey string + fromEmail string + logger *log.Logger + httpClient *http.Client } // New creates a new API handler. -func New(store *store.Store, apiKey string, logger *log.Logger) *Handler { +func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, logger *log.Logger) *Handler { return &Handler{ - store: store, - apiKey: apiKey, - logger: logger, + store: store, + apiKey: apiKey, + resendAPIKey: resendAPIKey, + fromEmail: fromEmail, + logger: logger, + httpClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -34,6 +42,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && path == "/report": h.handleReport(w, r) + case r.Method == http.MethodPost && path == "/notify": + h.handleNotify(w, r) case r.Method == http.MethodGet && path == "/customers": h.handleCustomers(w, r) case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"): @@ -182,3 +192,168 @@ func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } + +// handleNotify processes notification events from customer controllers. +func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) { + // Verify bearer token (same auth as /report) + 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"` + EventType string `json:"event_type"` + Severity string `json:"severity"` + Message string `json:"message"` + Details string `json:"details"` + } + if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" || payload.EventType == "" { + http.Error(w, "Invalid payload: customer_id and event_type required", http.StatusBadRequest) + return + } + + h.logger.Printf("[INFO] Notification from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message) + + // Look up customer notification preferences + prefs, err := h.store.GetNotificationPrefs(payload.CustomerID) + if err != nil { + h.logger.Printf("[ERROR] Failed to get notification prefs for %s: %v", payload.CustomerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + // Check if customer has email configured and event type is enabled + if prefs == nil || prefs.Email == "" { + h.logger.Printf("[INFO] No email configured for %s, skipping notification", payload.CustomerID) + h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "no email configured") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_email"}`)) + return + } + + // Check if event type is in the enabled list (test events always pass) + eventEnabled := payload.EventType == "test" + for _, e := range prefs.EnabledEvents { + if e == payload.EventType { + eventEnabled = true + break + } + } + if !eventEnabled { + h.logger.Printf("[INFO] Event %s not enabled for %s, skipping", payload.EventType, payload.CustomerID) + h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "event not enabled") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok","sent":false,"reason":"event_disabled"}`)) + return + } + + // Send email via Resend API + if h.resendAPIKey == "" { + h.logger.Printf("[WARN] Resend API key not configured, cannot send notification email") + h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "resend api key not configured") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_api_key"}`)) + return + } + + subject, emailBody := formatNotificationEmail(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, payload.Details) + sendErr := h.sendResendEmail(prefs.Email, subject, emailBody) + if sendErr != nil { + h.logger.Printf("[ERROR] Failed to send notification email to %s: %v", prefs.Email, sendErr) + h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "failed", sendErr.Error()) + http.Error(w, "Failed to send email", http.StatusInternalServerError) + return + } + + h.logger.Printf("[INFO] Notification email sent to %s for %s/%s", prefs.Email, payload.CustomerID, payload.EventType) + h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "sent", "") + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok","sent":true}`)) +} + +// sendResendEmail sends an email via the Resend HTTP API. +func (h *Handler) sendResendEmail(to, subject, textBody string) error { + payload := map[string]interface{}{ + "from": h.fromEmail, + "to": []string{to}, + "subject": subject, + "text": textBody, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling email payload: %w", err) + } + + req, err := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewReader(jsonData)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+h.resendAPIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := h.httpClient.Do(req) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("resend API returned %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// formatNotificationEmail creates a Hungarian email subject and body. +func formatNotificationEmail(customerID, eventType, severity, message, details string) (string, string) { + severityLabel := map[string]string{ + "info": "Információ", + "warning": "Figyelmeztetés", + "critical": "Kritikus", + } + label := severityLabel[severity] + if label == "" { + label = severity + } + + subject := fmt.Sprintf("[Felhom] %s: %s", label, message) + + now := time.Now().Format("2006-01-02 15:04") + emailText := fmt.Sprintf(`Kedves Ügyfél! + +A Felhom rendszered a következő figyelmeztetést jelezte: + +%s + +Részletek: +- Szerver: %s +- Időpont: %s +- Szint: %s +- Típus: %s`, message, customerID, now, label, eventType) + + if details != "" { + emailText += fmt.Sprintf("\n- Megjegyzés: %s", details) + } + + emailText += ` + +Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu monitoring` + + return subject, emailText +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 8d59cad..f95917a 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -69,10 +69,88 @@ func (s *Store) migrate() error { CREATE INDEX IF NOT EXISTS idx_reports_customer ON reports(customer_id, received_at DESC); + + CREATE TABLE IF NOT EXISTS customer_notifications ( + customer_id TEXT PRIMARY KEY, + email TEXT NOT NULL DEFAULT '', + enabled_events TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT (datetime('now')), + updated_at DATETIME DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS notification_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + event_type TEXT NOT NULL, + severity TEXT NOT NULL, + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT, + created_at DATETIME DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_notification_log_customer + ON notification_log(customer_id, created_at DESC); `) return err } +// NotificationPrefs holds per-customer notification preferences. +type NotificationPrefs struct { + CustomerID string + Email string + EnabledEvents []string +} + +// GetNotificationPrefs returns notification preferences for a customer. +func (s *Store) GetNotificationPrefs(customerID string) (*NotificationPrefs, error) { + var email, eventsJSON string + err := s.db.QueryRow( + "SELECT email, enabled_events FROM customer_notifications WHERE customer_id = ?", + customerID, + ).Scan(&email, &eventsJSON) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + + var events []string + json.Unmarshal([]byte(eventsJSON), &events) + + return &NotificationPrefs{ + CustomerID: customerID, + Email: email, + EnabledEvents: events, + }, nil +} + +// SaveNotificationPrefs creates or updates notification preferences for a customer. +func (s *Store) SaveNotificationPrefs(customerID, email string, enabledEvents []string) error { + eventsJSON, _ := json.Marshal(enabledEvents) + _, err := s.db.Exec(` + INSERT INTO customer_notifications (customer_id, email, enabled_events, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(customer_id) DO UPDATE SET + email = excluded.email, + enabled_events = excluded.enabled_events, + updated_at = datetime('now')`, + customerID, email, string(eventsJSON), + ) + return err +} + +// LogNotification records a notification attempt. +func (s *Store) LogNotification(customerID, eventType, severity, message, status, errorMsg string) error { + _, err := s.db.Exec(` + INSERT INTO notification_log (customer_id, event_type, severity, message, status, error_message) + VALUES (?, ?, ?, ?, ?, ?)`, + customerID, eventType, severity, message, status, errorMsg, + ) + return 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