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
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>
217 lines
6.7 KiB
Go
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
|
|
}
|