177 lines
6.2 KiB
Go
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
|
|
}
|