From 6dbb14d1461bef648b01b6544eef8d94a5686deb Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 18:17:19 +0330 Subject: [PATCH] feat(notifications+admin): marketing campaigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migrations/19_notification_campaigns.sql | 26 +++ messages/en.json | 3 +- messages/fa.json | 3 +- services/gateway/cmd/server/main.go | 1 + services/notification/cmd/server/main.go | 7 + .../notification/internal/db/campaigns.go | 110 ++++++++++++ .../internal/handlers/campaigns.go | 162 ++++++++++++++++++ .../notification/internal/models/models.go | 28 +++ src/app/[locale]/admin/layout.tsx | 1 + src/app/[locale]/admin/marketing/page.tsx | 7 + src/components/admin/MarketingAdmin.tsx | 150 ++++++++++++++++ 11 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 backend/db/migrations/19_notification_campaigns.sql create mode 100644 services/notification/internal/db/campaigns.go create mode 100644 services/notification/internal/handlers/campaigns.go create mode 100644 src/app/[locale]/admin/marketing/page.tsx create mode 100644 src/components/admin/MarketingAdmin.tsx diff --git a/backend/db/migrations/19_notification_campaigns.sql b/backend/db/migrations/19_notification_campaigns.sql new file mode 100644 index 0000000..b7edbe0 --- /dev/null +++ b/backend/db/migrations/19_notification_campaigns.sql @@ -0,0 +1,26 @@ +-- ===================================================================== +-- NOTIFICATION SCHEMA — Part 19: marketing campaigns +-- A campaign sends an SMS or Email to a user segment (resolved from identity.users) +-- via the configured channel providers. +-- ===================================================================== + +SET search_path TO notification, public; + +CREATE TABLE IF NOT EXISTS campaigns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + name TEXT NOT NULL, + channel TEXT NOT NULL, -- 'sms' | 'email' + audience TEXT NOT NULL DEFAULT 'all', -- 'all' | 'verified' | 'with_plan' + subject TEXT, -- email only + body_html TEXT, -- email body / sms text + template_code TEXT, -- optional email template + status TEXT NOT NULL DEFAULT 'Draft', -- Draft | Sending | Sent | Failed + total_count INT NOT NULL DEFAULT 0, + sent_count INT NOT NULL DEFAULT 0, + failed_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_campaigns_tenant ON campaigns (tenant_id, created_at DESC); diff --git a/messages/en.json b/messages/en.json index 73020ab..989d013 100644 --- a/messages/en.json +++ b/messages/en.json @@ -324,7 +324,8 @@ "media": "Media", "discounts": "Discounts", "siteSettings": "Settings", - "messaging": "Messaging" + "messaging": "Messaging", + "marketing": "Marketing" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index 0dfcbaa..b973d83 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -324,7 +324,8 @@ "media": "رسانه", "discounts": "تخفیف‌ها", "siteSettings": "تنظیمات سایت", - "messaging": "پیام‌رسانی" + "messaging": "پیام‌رسانی", + "marketing": "بازاریابی" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index 2fd9f00..adaabdb 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -142,6 +142,7 @@ func main() { v1.Any("/channels/*path", apiRL, auth, notification.Handler()) v1.Any("/sms/*path", apiRL, auth, notification.Handler()) v1.Any("/email/*path", apiRL, auth, notification.Handler()) + v1.Any("/campaigns/*path", apiRL, auth, notification.Handler()) log.Printf("gateway listening on :%s", port) if err := r.Run(":" + port); err != nil { diff --git a/services/notification/cmd/server/main.go b/services/notification/cmd/server/main.go index cd3da65..8f409dd 100644 --- a/services/notification/cmd/server/main.go +++ b/services/notification/cmd/server/main.go @@ -40,6 +40,7 @@ func main() { prefH := handlers.NewPreferenceHandler(store) tplH := handlers.NewTemplateHandler(store) chH := handlers.NewChannelHandler(store) + campH := handlers.NewCampaignHandler(store) r := gin.Default() @@ -93,6 +94,12 @@ func main() { v1.POST("/sms/send", auth, admin, chH.SendSMS) v1.POST("/email/send", auth, admin, chH.SendEmail) + // ── Marketing campaigns (admin) ─────────────────────────────────────────── + v1.GET("/campaigns", auth, admin, campH.List) + v1.POST("/campaigns", auth, admin, campH.Create) + v1.POST("/campaigns/:id/send", auth, admin, campH.Send) + v1.DELETE("/campaigns/:id", auth, admin, campH.Delete) + // ── Internal: create notification (service-to-service) ─────────────────── v1.POST("/internal/notifications", serviceAuth, notifH.CreateInternal) diff --git a/services/notification/internal/db/campaigns.go b/services/notification/internal/db/campaigns.go new file mode 100644 index 0000000..ab7e6aa --- /dev/null +++ b/services/notification/internal/db/campaigns.go @@ -0,0 +1,110 @@ +package db + +import ( + "context" + + "github.com/flatrender/notification-svc/internal/models" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (s *Store) ListCampaigns(ctx context.Context, tenantID uuid.UUID) ([]*models.Campaign, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, tenant_id, name, channel, audience, subject, body_html, template_code, + status, total_count, sent_count, failed_count, created_at, sent_at + FROM notification.campaigns WHERE tenant_id = $1 ORDER BY created_at DESC`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*models.Campaign + for rows.Next() { + c := &models.Campaign{} + if err := rows.Scan(&c.ID, &c.TenantID, &c.Name, &c.Channel, &c.Audience, &c.Subject, + &c.BodyHTML, &c.TemplateCode, &c.Status, &c.TotalCount, &c.SentCount, &c.FailedCount, + &c.CreatedAt, &c.SentAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (s *Store) GetCampaign(ctx context.Context, id, tenantID uuid.UUID) (*models.Campaign, error) { + c := &models.Campaign{} + err := s.pool.QueryRow(ctx, + `SELECT id, tenant_id, name, channel, audience, subject, body_html, template_code, + status, total_count, sent_count, failed_count, created_at, sent_at + FROM notification.campaigns WHERE id = $1 AND tenant_id = $2`, id, tenantID). + Scan(&c.ID, &c.TenantID, &c.Name, &c.Channel, &c.Audience, &c.Subject, &c.BodyHTML, + &c.TemplateCode, &c.Status, &c.TotalCount, &c.SentCount, &c.FailedCount, &c.CreatedAt, &c.SentAt) + if err == pgx.ErrNoRows { + return nil, nil + } + return c, err +} + +func (s *Store) CreateCampaign(ctx context.Context, tenantID uuid.UUID, req models.CreateCampaignRequest) (*models.Campaign, error) { + audience := req.Audience + if audience == "" { + audience = "all" + } + var id uuid.UUID + err := s.pool.QueryRow(ctx, + `INSERT INTO notification.campaigns (tenant_id, name, channel, audience, subject, body_html, template_code) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + tenantID, req.Name, req.Channel, audience, req.Subject, req.BodyHTML, req.TemplateCode).Scan(&id) + if err != nil { + return nil, err + } + return s.GetCampaign(ctx, id, tenantID) +} + +func (s *Store) DeleteCampaign(ctx context.Context, id, tenantID uuid.UUID) error { + _, err := s.pool.Exec(ctx, `DELETE FROM notification.campaigns WHERE id=$1 AND tenant_id=$2`, id, tenantID) + return err +} + +func (s *Store) UpdateCampaignResult(ctx context.Context, id uuid.UUID, status string, total, sent, failed int) error { + _, err := s.pool.Exec(ctx, + `UPDATE notification.campaigns SET status=$2, total_count=$3, sent_count=$4, failed_count=$5, + sent_at = CASE WHEN $2 IN ('Sent','Failed') THEN NOW() ELSE sent_at END + WHERE id=$1`, id, status, total, sent, failed) + return err +} + +// ResolveAudience returns recipient contact strings (email or phone) for a campaign +// segment, read cross-schema from identity.users (same database). +func (s *Store) ResolveAudience(ctx context.Context, tenantID uuid.UUID, channel, audience string, limit int) ([]string, error) { + col := "email" + notNull := "email IS NOT NULL AND email <> ''" + if channel == "sms" { + col = "phone_number" + notNull = "phone_number IS NOT NULL AND phone_number <> ''" + } + where := "tenant_id = $1 AND deleted_at IS NULL AND ban_account = FALSE AND " + notNull + switch audience { + case "verified": + if channel == "sms" { + where += " AND phone_verified = TRUE" + } else { + where += " AND email_verified = TRUE" + } + case "with_plan": + where += " AND EXISTS (SELECT 1 FROM identity.user_plans up WHERE up.user_id = identity.users.id AND (up.expires_at IS NULL OR up.expires_at > NOW()))" + } + q := "SELECT " + col + " FROM identity.users WHERE " + where + " LIMIT $2" + rows, err := s.pool.Query(ctx, q, tenantID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err == nil && v != "" { + out = append(out, v) + } + } + return out, rows.Err() +} diff --git a/services/notification/internal/handlers/campaigns.go b/services/notification/internal/handlers/campaigns.go new file mode 100644 index 0000000..dfd5bed --- /dev/null +++ b/services/notification/internal/handlers/campaigns.go @@ -0,0 +1,162 @@ +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}) +} diff --git a/services/notification/internal/models/models.go b/services/notification/internal/models/models.go index b8be6a0..932086d 100644 --- a/services/notification/internal/models/models.go +++ b/services/notification/internal/models/models.go @@ -223,3 +223,31 @@ type SendEmailRequest struct { Locale string `json:"locale"` Variables map[string]string `json:"variables"` } + +// ── Marketing campaigns ────────────────────────────────────────────────────── + +type Campaign struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + Name string `json:"name"` + Channel string `json:"channel"` // sms | email + Audience string `json:"audience"` // all | verified | with_plan + Subject *string `json:"subject,omitempty"` + BodyHTML *string `json:"body_html,omitempty"` + TemplateCode *string `json:"template_code,omitempty"` + Status string `json:"status"` + TotalCount int `json:"total_count"` + SentCount int `json:"sent_count"` + FailedCount int `json:"failed_count"` + CreatedAt time.Time `json:"created_at"` + SentAt *time.Time `json:"sent_at,omitempty"` +} + +type CreateCampaignRequest struct { + Name string `json:"name" binding:"required"` + Channel string `json:"channel" binding:"required"` // sms | email + Audience string `json:"audience"` + Subject *string `json:"subject"` + BodyHTML *string `json:"body_html"` + TemplateCode *string `json:"template_code"` +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index 1d520cf..afc5625 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -25,6 +25,7 @@ export default async function AdminLayout({ { href: "/admin/files", label: t("media") }, { href: "/admin/ai", label: t("aiContent") }, { href: "/admin/messaging", label: t("messaging") }, + { href: "/admin/marketing", label: t("marketing") }, { href: "/admin/users", label: t("users") }, { href: "/admin/plans", label: t("plans") }, { href: "/admin/discounts", label: t("discounts") }, diff --git a/src/app/[locale]/admin/marketing/page.tsx b/src/app/[locale]/admin/marketing/page.tsx new file mode 100644 index 0000000..af78efb --- /dev/null +++ b/src/app/[locale]/admin/marketing/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { MarketingAdmin } from "@/components/admin/MarketingAdmin"; + +export default function Page() { + return ; +} diff --git a/src/components/admin/MarketingAdmin.tsx b/src/components/admin/MarketingAdmin.tsx new file mode 100644 index 0000000..99b428f --- /dev/null +++ b/src/components/admin/MarketingAdmin.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +interface Campaign { + id: string; + name: string; + channel: string; + audience: string; + subject?: string | null; + body_html?: string | null; + template_code?: string | null; + status: string; + total_count: number; + sent_count: number; + failed_count: number; + created_at: string; +} + +const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5"; +const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50"; +const ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50"; +const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500"; +const lbl = "mb-1 block text-xs font-medium text-gray-400"; + +const empty = { name: "", channel: "email", audience: "all", subject: "", body_html: "", template_code: "" }; + +export function MarketingAdmin() { + const [rows, setRows] = useState([]); + const [form, setForm] = useState({ ...empty }); + const [saving, setSaving] = useState(false); + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState(null); + + const reload = useCallback(async () => { + const r = await fetch("/api/admin/resource/campaigns", { cache: "no-store" }).then((x) => x.json()).catch(() => null); + setRows(r?.data ?? (Array.isArray(r) ? r : [])); + }, []); + useEffect(() => { reload(); }, [reload]); + + const create = async () => { + setSaving(true); setMsg(null); + const res = await fetch("/api/admin/resource/campaigns", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: form.name, channel: form.channel, audience: form.audience, + subject: form.subject || null, body_html: form.body_html || null, + template_code: form.template_code || null, + }), + }); + const d = await res.json().catch(() => null); + setMsg(res.ok ? "کمپین ساخته شد ✓" : (d?.error ?? "خطا")); + setSaving(false); + if (res.ok) { setForm({ ...empty }); reload(); } + }; + + const send = async (camp: Campaign) => { + if (!confirm(`ارسال کمپین «${camp.name}» به مخاطبان؟`)) return; + setBusy(camp.id); setMsg(null); + const res = await fetch(`/api/admin/resource/campaigns/${camp.id}/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); + const d = await res.json().catch(() => null); + setMsg(res.ok ? `ارسال شد: ${d.sent}/${d.total} (ناموفق ${d.failed})` : (d?.error ?? "ارسال ناموفق")); + setBusy(null); reload(); + }; + + const remove = async (camp: Campaign) => { + if (!confirm(`حذف کمپین «${camp.name}»؟`)) return; + const res = await fetch(`/api/admin/resource/campaigns/${camp.id}`, { method: "DELETE" }); + if (res.ok) reload(); + }; + + const statusBadge = (s: string) => { + const map: Record = { + Sent: "bg-emerald-500/15 text-emerald-300", + Sending: "bg-amber-500/15 text-amber-300", + Failed: "bg-red-500/15 text-red-300", + Draft: "bg-gray-500/15 text-gray-400", + }; + return {s}; + }; + + return ( +
+
+

Marketing — Campaigns

+

ارسال پیامک/ایمیل به گروهی از کاربران از طریق کانال‌های پیکربندی‌شده.

+
+ {msg &&

{msg}

} + +
+

کمپین جدید

+
+
setForm({ ...form, name: e.target.value })} />
+
+ + +
+
+ + +
+ {form.channel === "email" && ( +
setForm({ ...form, template_code: e.target.value })} />
+ )} + {form.channel === "email" && ( +
setForm({ ...form, subject: e.target.value })} />
+ )} +
+ +