6dbb14d146
Build backend images / build content-svc (push) Failing after 14s
Build backend images / build file-svc (push) Failing after 22s
Build backend images / build gateway (push) Failing after 1m21s
Build backend images / build identity-svc (push) Failing after 1m43s
Build backend images / build notification-svc (push) Failing after 1m6s
Build backend images / build render-svc (push) Failing after 53s
Build backend images / build studio-svc (push) Failing after 1m5s
- campaigns table (migration 19) + CRUD + send endpoint in notification-svc - audience resolution reads cross-schema from identity.users (all / verified / with_plan); send dispatches via the SMS or Email channel and logs deliveries - endpoints: GET/POST /v1/campaigns, POST /v1/campaigns/:id/send, DELETE - gateway route /v1/campaigns/* → notification - /admin/marketing: create campaign (channel, audience, template/subject/body), list with status + sent counts, send, delete Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
5.1 KiB
Go
163 lines
5.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"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"
|
|
)
|
|
|
|
// Max recipients per campaign send (V1 sends synchronously).
|
|
const campaignRecipientCap = 1000
|
|
|
|
type CampaignHandler struct{ store *db.Store }
|
|
|
|
func NewCampaignHandler(s *db.Store) *CampaignHandler { return &CampaignHandler{store: s} }
|
|
|
|
func (h *CampaignHandler) tenant(c *gin.Context) uuid.UUID {
|
|
v, _ := c.Get(middleware.CtxTenantID)
|
|
id, _ := v.(uuid.UUID)
|
|
return id
|
|
}
|
|
func (h *CampaignHandler) uid(c *gin.Context) uuid.UUID {
|
|
v, _ := c.Get(middleware.CtxUserID)
|
|
id, _ := v.(uuid.UUID)
|
|
return id
|
|
}
|
|
|
|
// GET /v1/campaigns
|
|
func (h *CampaignHandler) List(c *gin.Context) {
|
|
list, err := h.store.ListCampaigns(c.Request.Context(), h.tenant(c))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": list})
|
|
}
|
|
|
|
// POST /v1/campaigns
|
|
func (h *CampaignHandler) Create(c *gin.Context) {
|
|
var req models.CreateCampaignRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: err.Error()})
|
|
return
|
|
}
|
|
if req.Channel != "sms" && req.Channel != "email" {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "channel must be sms or email"})
|
|
return
|
|
}
|
|
camp, err := h.store.CreateCampaign(c.Request.Context(), h.tenant(c), req)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, camp)
|
|
}
|
|
|
|
// DELETE /v1/campaigns/:id
|
|
func (h *CampaignHandler) Delete(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
|
return
|
|
}
|
|
if err := h.store.DeleteCampaign(c.Request.Context(), id, h.tenant(c)); 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/campaigns/:id/send
|
|
func (h *CampaignHandler) Send(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
|
|
return
|
|
}
|
|
tenant := h.tenant(c)
|
|
camp, _ := h.store.GetCampaign(ctx, id, tenant)
|
|
if camp == nil {
|
|
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "campaign not found"})
|
|
return
|
|
}
|
|
cfg, _ := h.store.GetChannelConfig(ctx, tenant, camp.Channel)
|
|
if cfg == nil || !cfg.Enabled {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "not_configured", Message: camp.Channel + " channel is not configured/enabled"})
|
|
return
|
|
}
|
|
|
|
// Resolve subject/body (from template for email, else campaign fields).
|
|
subject, body := "", ""
|
|
if camp.Subject != nil {
|
|
subject = *camp.Subject
|
|
}
|
|
if camp.BodyHTML != nil {
|
|
body = *camp.BodyHTML
|
|
}
|
|
if camp.Channel == "email" && camp.TemplateCode != nil && *camp.TemplateCode != "" {
|
|
if tpl, _ := h.store.GetEmailTemplate(ctx, *camp.TemplateCode, "fa"); tpl != nil {
|
|
if tpl.Subject != nil {
|
|
subject = *tpl.Subject
|
|
}
|
|
if tpl.BodyHTML != nil {
|
|
body = *tpl.BodyHTML
|
|
}
|
|
}
|
|
}
|
|
|
|
recipients, err := h.store.ResolveAudience(ctx, tenant, camp.Channel, camp.Audience, campaignRecipientCap)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
|
|
return
|
|
}
|
|
total := len(recipients)
|
|
_ = h.store.UpdateCampaignResult(ctx, id, "Sending", total, 0, 0)
|
|
|
|
var scfg sender.SMTPConfig
|
|
if camp.Channel == "email" {
|
|
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"]),
|
|
}
|
|
}
|
|
|
|
sent, failed := 0, 0
|
|
provider := map[string]string{"sms": "kavenegar", "email": "smtp"}[camp.Channel]
|
|
chLabel := map[string]string{"sms": "SMS", "email": "Email"}[camp.Channel]
|
|
for _, r := range recipients {
|
|
var sendErr error
|
|
if camp.Channel == "email" {
|
|
sendErr = sender.SendEmail(scfg, r, subject, body)
|
|
} else {
|
|
_, sendErr = sender.SendSMS(str(cfg.Settings["api_key"]), str(cfg.Settings["line_number"]), r, body)
|
|
}
|
|
status := "Sent"
|
|
var em *string
|
|
if sendErr != nil {
|
|
failed++
|
|
status = "Failed"
|
|
e := sendErr.Error()
|
|
em = &e
|
|
} else {
|
|
sent++
|
|
}
|
|
subj := &subject
|
|
_ = h.store.LogDelivery(ctx, tenant, h.uid(c), chLabel, r, subj, &provider, nil, &status, em)
|
|
}
|
|
|
|
final := "Sent"
|
|
if total > 0 && sent == 0 {
|
|
final = "Failed"
|
|
}
|
|
_ = h.store.UpdateCampaignResult(ctx, id, final, total, sent, failed)
|
|
c.JSON(http.StatusOK, gin.H{"status": final, "total": total, "sent": sent, "failed": failed})
|
|
}
|