veylant/internal/notifications/handler.go
2026-03-10 12:01:34 +01:00

177 lines
6.2 KiB
Go

package notifications
import (
"encoding/json"
"fmt"
"net/http"
"go.uber.org/zap"
"github.com/veylant/ia-gateway/internal/apierror"
"github.com/veylant/ia-gateway/internal/middleware"
)
// Handler handles POST /v1/notifications/send — sends a budget alert email.
// Requires a valid JWT (caller must be authenticated).
type Handler struct {
mailer *Mailer
logger *zap.Logger
}
// NewHandler creates a Handler. mailer must not be nil.
func NewHandler(mailer *Mailer, logger *zap.Logger) *Handler {
return &Handler{mailer: mailer, logger: logger}
}
// SendRequest is the JSON body for POST /v1/notifications/send.
type SendRequest struct {
To string `json:"to"` // recipient email address
AlertName string `json:"alert_name"` // human-readable rule name
Scope string `json:"scope"` // "global" | provider name
Spend float64 `json:"spend"` // current spend for the scope
Threshold float64 `json:"threshold"` // configured threshold
Type string `json:"type"` // "budget_exceeded" | "budget_warning"
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
reqID := middleware.RequestIDFromContext(r.Context())
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.WriteErrorWithRequestID(w, &apierror.APIError{
Type: "invalid_request_error", Message: "Invalid JSON body",
Code: "invalid_request", HTTPStatus: http.StatusBadRequest,
}, reqID)
return
}
if req.To == "" {
apierror.WriteErrorWithRequestID(w, &apierror.APIError{
Type: "invalid_request_error", Message: "Field 'to' is required",
Code: "invalid_request", HTTPStatus: http.StatusBadRequest,
}, reqID)
return
}
if req.AlertName == "" {
apierror.WriteErrorWithRequestID(w, &apierror.APIError{
Type: "invalid_request_error", Message: "Field 'alert_name' is required",
Code: "invalid_request", HTTPStatus: http.StatusBadRequest,
}, reqID)
return
}
subject, html := buildEmail(req)
if err := h.mailer.Send(Message{To: req.To, Subject: subject, HTML: html}); err != nil {
h.logger.Warn("notification email send failed",
zap.Error(err),
zap.String("to", req.To),
zap.String("alert", req.AlertName),
zap.String("request_id", reqID),
)
apierror.WriteErrorWithRequestID(w, &apierror.APIError{
Type: "api_error",
Message: "Failed to send notification email — check SMTP configuration",
Code: "email_send_failed",
HTTPStatus: http.StatusServiceUnavailable,
}, reqID)
return
}
h.logger.Info("notification email sent",
zap.String("to", req.To),
zap.String("alert", req.AlertName),
zap.String("type", req.Type),
)
w.WriteHeader(http.StatusNoContent)
}
// buildEmail returns the subject and HTML body for a budget alert notification.
func buildEmail(req SendRequest) (subject, html string) {
isExceeded := req.Type == "budget_exceeded"
scopeLabel := req.Scope
if req.Scope == "global" {
scopeLabel = "Tous fournisseurs"
}
pct := 0.0
if req.Threshold > 0 {
pct = (req.Spend / req.Threshold) * 100
}
accentColor := "#f59e0b" // warning amber
statusLabel := "Alerte 80 % — seuil bientôt atteint"
emoji := "⚠️"
if isExceeded {
accentColor = "#ef4444" // destructive red
statusLabel = "Seuil dépassé"
emoji = "🚨"
}
subject = fmt.Sprintf("[Veylant IA] %s %s : %s", emoji, statusLabel, req.AlertName)
html = fmt.Sprintf(`<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:24px;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="max-width:500px;margin:0 auto;background:#ffffff;border-radius:10px;border:1px solid #e5e7eb;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
<!-- Header band -->
<div style="background:%s;padding:20px 28px;">
<p style="margin:0;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.75);">Veylant IA · Governance Hub</p>
<h1 style="margin:6px 0 0;font-size:20px;font-weight:700;color:#fff;">%s</h1>
</div>
<!-- Body -->
<div style="padding:28px;">
<h2 style="margin:0 0 18px;font-size:16px;font-weight:600;color:#111827;">Règle : %s</h2>
<table style="width:100%%;border-collapse:collapse;font-size:14px;color:#374151;">
<tr>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;">Périmètre</td>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:500;text-transform:capitalize;">%s</td>
</tr>
<tr>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;">Dépenses actuelles</td>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:700;color:%s;">$%.6f</td>
</tr>
<tr>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;">Seuil configuré</td>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:500;">$%.6f</td>
</tr>
<tr>
<td style="padding:10px 0;color:#6b7280;">Consommation</td>
<td style="padding:10px 0;text-align:right;font-weight:700;color:%s;">%.1f %%</td>
</tr>
</table>
<!-- Progress bar -->
<div style="margin-top:18px;background:#f3f4f6;border-radius:99px;height:8px;overflow:hidden;">
<div style="height:8px;border-radius:99px;background:%s;width:%.0f%%;transition:width .3s;"></div>
</div>
<p style="margin:6px 0 0;font-size:12px;color:#6b7280;text-align:right;">%.1f %% du seuil</p>
</div>
<!-- Footer -->
<div style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.5;">
Cet email a été envoyé automatiquement par <strong style="color:#6b7280;">Veylant IA</strong>.<br>
Gérez vos règles d'alerte dans <em>Paramètres → Notifications</em>.
</p>
</div>
</div>
</body>
</html>`,
accentColor, statusLabel,
req.AlertName,
scopeLabel,
accentColor, req.Spend,
req.Threshold,
accentColor, pct,
accentColor, pct,
pct,
)
return subject, html
}