feat(notifications+admin): SMS (Kavenegar) + Email (SMTP) channels & templates
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
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
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>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user