Files
flatrender/services/notification/internal/handlers/channels.go
T
soroush.asadi 507ac7e6a4
Build backend images / build content-svc (push) Failing after 56s
Build backend images / build file-svc (push) Failing after 47s
Build backend images / build gateway (push) Failing after 1m0s
Build backend images / build identity-svc (push) Failing after 56s
Build backend images / build notification-svc (push) Failing after 11s
Build backend images / build render-svc (push) Failing after 4m5s
Build backend images / build studio-svc (push) Failing after 56s
feat(notifications+admin): SMS (Kavenegar) + Email (SMTP) channels & templates
Backend (notification-svc):
- channel_config table (per-tenant Kavenegar + SMTP settings) + migration 18
- sender pkg: Kavenegar SMS client + SMTP mailer (STARTTLS / implicit TLS), stdlib only
- endpoints: GET/PUT /v1/channels[/:channel], POST /v1/sms/send, POST /v1/email/send
  (template + {{var}} rendering); deliveries logged
- seeded 3 Persian email templates: welcome / account_verification / promotion
- gateway routes /v1/{channels,sms,email}/* → notification

Admin UI:
- /admin/messaging: SMS + Email provider config cards, test-send, email template editor
- nav link + fa/en labels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:32:54 +03:30

217 lines
6.7 KiB
Go

package handlers
import (
"net/http"
"strings"
"github.com/flatrender/notification-svc/internal/db"
"github.com/flatrender/notification-svc/internal/middleware"
"github.com/flatrender/notification-svc/internal/models"
"github.com/flatrender/notification-svc/internal/sender"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ChannelHandler struct{ store *db.Store }
func NewChannelHandler(s *db.Store) *ChannelHandler { return &ChannelHandler{store: s} }
func (h *ChannelHandler) tenant(c *gin.Context) uuid.UUID {
v, _ := c.Get(middleware.CtxTenantID)
id, _ := v.(uuid.UUID)
return id
}
func (h *ChannelHandler) user(c *gin.Context) uuid.UUID {
v, _ := c.Get(middleware.CtxUserID)
id, _ := v.(uuid.UUID)
return id
}
// GET /v1/channels — both provider configs, secrets masked.
func (h *ChannelHandler) ListConfigs(c *gin.Context) {
cfgs, err := h.store.GetChannelConfigs(c.Request.Context(), h.tenant(c))
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
out := map[string]*models.ChannelConfig{
"sms": {Channel: "sms", Settings: map[string]any{}},
"email": {Channel: "email", Settings: map[string]any{}},
}
for _, cf := range cfgs {
out[cf.Channel] = cf
}
mask(out["sms"], "api_key")
mask(out["email"], "password")
c.JSON(http.StatusOK, gin.H{"sms": out["sms"], "email": out["email"]})
}
// PUT /v1/channels/:channel — upsert provider settings (blank secret keeps existing).
func (h *ChannelHandler) UpsertConfig(c *gin.Context) {
channel := c.Param("channel")
if channel != "sms" && channel != "email" {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "channel must be sms or email"})
return
}
var req models.UpsertChannelConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
return
}
settings := req.Settings
if settings == nil {
settings = map[string]any{}
}
enabled := false
if req.Enabled != nil {
enabled = *req.Enabled
}
secretKey := "api_key"
if channel == "email" {
secretKey = "password"
}
// Preserve the secret if the client sent it blank/omitted (it's masked on read).
existing, _ := h.store.GetChannelConfig(c.Request.Context(), h.tenant(c), channel)
if existing != nil {
if v, ok := settings[secretKey]; !ok || str(v) == "" || strings.Contains(str(v), "•") {
if ev, ok := existing.Settings[secretKey]; ok {
settings[secretKey] = ev
}
}
}
if err := h.store.UpsertChannelConfig(c.Request.Context(), h.tenant(c), channel, settings, enabled); err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// POST /v1/sms/send — send an SMS via Kavenegar.
func (h *ChannelHandler) SendSMS(c *gin.Context) {
var req models.SendSMSRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
return
}
cfg, _ := h.store.GetChannelConfig(c.Request.Context(), h.tenant(c), "sms")
if cfg == nil || !cfg.Enabled {
c.JSON(http.StatusBadRequest, models.APIError{Code: "not_configured", Message: "SMS channel is not configured/enabled"})
return
}
msgID, err := sender.SendSMS(str(cfg.Settings["api_key"]), str(cfg.Settings["line_number"]), req.To, req.Message)
status, errMsg := "Sent", (*string)(nil)
if err != nil {
status = "Failed"
e := err.Error()
errMsg = &e
}
provider := "kavenegar"
_ = h.store.LogDelivery(c.Request.Context(), h.tenant(c), h.user(c), "SMS", req.To, nil, &provider, ptr(msgID), &status, errMsg)
if err != nil {
c.JSON(http.StatusBadGateway, models.APIError{Code: "send_failed", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent", "message_id": msgID})
}
// POST /v1/email/send — send an email via SMTP (optionally from a template + variables).
func (h *ChannelHandler) SendEmail(c *gin.Context) {
var req models.SendEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
return
}
subject, html := req.Subject, req.BodyHTML
if req.TemplateCode != "" {
locale := req.Locale
if locale == "" {
locale = "fa"
}
tpl, _ := h.store.GetEmailTemplate(c.Request.Context(), req.TemplateCode, locale)
if tpl == nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "email template not found"})
return
}
if tpl.Subject != nil {
subject = *tpl.Subject
}
if tpl.BodyHTML != nil {
html = *tpl.BodyHTML
}
for k, v := range req.Variables {
subject = strings.ReplaceAll(subject, "{{"+k+"}}", v)
html = strings.ReplaceAll(html, "{{"+k+"}}", v)
}
}
cfg, _ := h.store.GetChannelConfig(c.Request.Context(), h.tenant(c), "email")
if cfg == nil || !cfg.Enabled {
c.JSON(http.StatusBadRequest, models.APIError{Code: "not_configured", Message: "Email channel is not configured/enabled"})
return
}
scfg := sender.SMTPConfig{
Host: str(cfg.Settings["host"]),
Port: intv(cfg.Settings["port"]),
Username: str(cfg.Settings["username"]),
Password: str(cfg.Settings["password"]),
FromEmail: str(cfg.Settings["from_email"]),
FromName: str(cfg.Settings["from_name"]),
UseTLS: boolv(cfg.Settings["use_tls"]),
}
err := sender.SendEmail(scfg, req.To, subject, html)
status, errMsg := "Sent", (*string)(nil)
if err != nil {
status = "Failed"
e := err.Error()
errMsg = &e
}
provider := "smtp"
_ = h.store.LogDelivery(c.Request.Context(), h.tenant(c), h.user(c), "Email", req.To, &subject, &provider, nil, &status, errMsg)
if err != nil {
c.JSON(http.StatusBadGateway, models.APIError{Code: "send_failed", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
}
// ── helpers ──────────────────────────────────────────────────────────────────
func mask(cfg *models.ChannelConfig, key string) {
if cfg == nil || cfg.Settings == nil {
return
}
if v, ok := cfg.Settings[key]; ok && str(v) != "" {
s := str(v)
if len(s) > 4 {
cfg.Settings[key] = "••••••••" + s[len(s)-4:]
} else {
cfg.Settings[key] = "••••"
}
}
}
func str(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func intv(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
return 0
}
func boolv(v any) bool {
b, _ := v.(bool)
return b
}
func ptr(s string) *string {
if s == "" {
return nil
}
return &s
}