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 <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -31,6 +31,10 @@ type Config struct {
|
|||||||
API struct {
|
API struct {
|
||||||
ReportAPIKey string `yaml:"report_api_key"`
|
ReportAPIKey string `yaml:"report_api_key"`
|
||||||
} `yaml:"api"`
|
} `yaml:"api"`
|
||||||
|
Notifications struct {
|
||||||
|
ResendAPIKey string `yaml:"resend_api_key"`
|
||||||
|
FromEmail string `yaml:"from_email"`
|
||||||
|
} `yaml:"notifications"`
|
||||||
Retention struct {
|
Retention struct {
|
||||||
MaxDays int `yaml:"max_days"`
|
MaxDays int `yaml:"max_days"`
|
||||||
PruneSchedule string `yaml:"prune_schedule"`
|
PruneSchedule string `yaml:"prune_schedule"`
|
||||||
@@ -79,7 +83,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize handlers
|
// 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)
|
webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger)
|
||||||
|
|
||||||
// Build HTTP mux
|
// Build HTTP mux
|
||||||
@@ -177,6 +181,9 @@ func loadConfig(path string, logger *log.Logger) *Config {
|
|||||||
if cfg.Alerting.StaleThreshold == "" {
|
if cfg.Alerting.StaleThreshold == "" {
|
||||||
cfg.Alerting.StaleThreshold = "30m"
|
cfg.Alerting.StaleThreshold = "30m"
|
||||||
}
|
}
|
||||||
|
if cfg.Notifications.FromEmail == "" {
|
||||||
|
cfg.Notifications.FromEmail = "monitoring@felhom.eu"
|
||||||
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ api:
|
|||||||
# Bearer token required for report ingest (POST /api/v1/report)
|
# Bearer token required for report ingest (POST /api/v1/report)
|
||||||
report_api_key: ""
|
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 ---
|
# --- Data retention ---
|
||||||
retention:
|
retention:
|
||||||
max_days: 90 # Keep 90 days of report history
|
max_days: 90 # Keep 90 days of report history
|
||||||
|
|||||||
+182
-7
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -13,17 +15,23 @@ import (
|
|||||||
|
|
||||||
// Handler handles API endpoints for report ingest and customer queries.
|
// Handler handles API endpoints for report ingest and customer queries.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
apiKey string
|
apiKey string
|
||||||
logger *log.Logger
|
resendAPIKey string
|
||||||
|
fromEmail string
|
||||||
|
logger *log.Logger
|
||||||
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new API handler.
|
// 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{
|
return &Handler{
|
||||||
store: store,
|
store: store,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
logger: logger,
|
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 {
|
switch {
|
||||||
case r.Method == http.MethodPost && path == "/report":
|
case r.Method == http.MethodPost && path == "/report":
|
||||||
h.handleReport(w, r)
|
h.handleReport(w, r)
|
||||||
|
case r.Method == http.MethodPost && path == "/notify":
|
||||||
|
h.handleNotify(w, r)
|
||||||
case r.Method == http.MethodGet && path == "/customers":
|
case r.Method == http.MethodGet && path == "/customers":
|
||||||
h.handleCustomers(w, r)
|
h.handleCustomers(w, r)
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"):
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(result)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,10 +69,88 @@ func (s *Store) migrate() error {
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_reports_customer
|
CREATE INDEX IF NOT EXISTS idx_reports_customer
|
||||||
ON reports(customer_id, received_at DESC);
|
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
|
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.
|
// SaveReport stores a new report. The reportJSON should be the raw JSON payload.
|
||||||
func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
|
func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
|
||||||
// Parse denormalized fields from the JSON
|
// Parse denormalized fields from the JSON
|
||||||
|
|||||||
Reference in New Issue
Block a user