feat(notifications+admin): marketing campaigns
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
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>
This commit is contained in:
@@ -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);
|
||||||
+2
-1
@@ -324,7 +324,8 @@
|
|||||||
"media": "Media",
|
"media": "Media",
|
||||||
"discounts": "Discounts",
|
"discounts": "Discounts",
|
||||||
"siteSettings": "Settings",
|
"siteSettings": "Settings",
|
||||||
"messaging": "Messaging"
|
"messaging": "Messaging",
|
||||||
|
"marketing": "Marketing"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "Render Nodes",
|
"title": "Render Nodes",
|
||||||
|
|||||||
+2
-1
@@ -324,7 +324,8 @@
|
|||||||
"media": "رسانه",
|
"media": "رسانه",
|
||||||
"discounts": "تخفیفها",
|
"discounts": "تخفیفها",
|
||||||
"siteSettings": "تنظیمات سایت",
|
"siteSettings": "تنظیمات سایت",
|
||||||
"messaging": "پیامرسانی"
|
"messaging": "پیامرسانی",
|
||||||
|
"marketing": "بازاریابی"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "نودهای رندر",
|
"title": "نودهای رندر",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ func main() {
|
|||||||
v1.Any("/channels/*path", apiRL, auth, notification.Handler())
|
v1.Any("/channels/*path", apiRL, auth, notification.Handler())
|
||||||
v1.Any("/sms/*path", apiRL, auth, notification.Handler())
|
v1.Any("/sms/*path", apiRL, auth, notification.Handler())
|
||||||
v1.Any("/email/*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)
|
log.Printf("gateway listening on :%s", port)
|
||||||
if err := r.Run(":" + port); err != nil {
|
if err := r.Run(":" + port); err != nil {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func main() {
|
|||||||
prefH := handlers.NewPreferenceHandler(store)
|
prefH := handlers.NewPreferenceHandler(store)
|
||||||
tplH := handlers.NewTemplateHandler(store)
|
tplH := handlers.NewTemplateHandler(store)
|
||||||
chH := handlers.NewChannelHandler(store)
|
chH := handlers.NewChannelHandler(store)
|
||||||
|
campH := handlers.NewCampaignHandler(store)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
@@ -93,6 +94,12 @@ func main() {
|
|||||||
v1.POST("/sms/send", auth, admin, chH.SendSMS)
|
v1.POST("/sms/send", auth, admin, chH.SendSMS)
|
||||||
v1.POST("/email/send", auth, admin, chH.SendEmail)
|
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) ───────────────────
|
// ── Internal: create notification (service-to-service) ───────────────────
|
||||||
v1.POST("/internal/notifications", serviceAuth, notifH.CreateInternal)
|
v1.POST("/internal/notifications", serviceAuth, notifH.CreateInternal)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -223,3 +223,31 @@ type SendEmailRequest struct {
|
|||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
Variables map[string]string `json:"variables"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default async function AdminLayout({
|
|||||||
{ href: "/admin/files", label: t("media") },
|
{ href: "/admin/files", label: t("media") },
|
||||||
{ href: "/admin/ai", label: t("aiContent") },
|
{ href: "/admin/ai", label: t("aiContent") },
|
||||||
{ href: "/admin/messaging", label: t("messaging") },
|
{ href: "/admin/messaging", label: t("messaging") },
|
||||||
|
{ href: "/admin/marketing", label: t("marketing") },
|
||||||
{ href: "/admin/users", label: t("users") },
|
{ href: "/admin/users", label: t("users") },
|
||||||
{ href: "/admin/plans", label: t("plans") },
|
{ href: "/admin/plans", label: t("plans") },
|
||||||
{ href: "/admin/discounts", label: t("discounts") },
|
{ href: "/admin/discounts", label: t("discounts") },
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MarketingAdmin } from "@/components/admin/MarketingAdmin";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <MarketingAdmin />;
|
||||||
|
}
|
||||||
@@ -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<Campaign[]>([]);
|
||||||
|
const [form, setForm] = useState({ ...empty });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [msg, setMsg] = useState<string | null>(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<string, string> = {
|
||||||
|
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 <span className={`rounded px-1.5 py-0.5 text-[11px] ${map[s] ?? map.Draft}`}>{s}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Marketing — Campaigns</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-400">ارسال پیامک/ایمیل به گروهی از کاربران از طریق کانالهای پیکربندیشده.</p>
|
||||||
|
</div>
|
||||||
|
{msg && <p className="rounded-lg bg-[#12152a] px-3 py-2 text-sm text-gray-300">{msg}</p>}
|
||||||
|
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="text-sm font-semibold text-white">کمپین جدید</h2>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div><label className={lbl}>نام کمپین</label><input className={inp} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>کانال</label>
|
||||||
|
<select className={inp} value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value })}>
|
||||||
|
<option value="email">ایمیل</option><option value="sms">پیامک</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={lbl}>مخاطبان</label>
|
||||||
|
<select className={inp} value={form.audience} onChange={(e) => setForm({ ...form, audience: e.target.value })}>
|
||||||
|
<option value="all">همه کاربران</option>
|
||||||
|
<option value="verified">کاربران تأییدشده</option>
|
||||||
|
<option value="with_plan">دارای پلن فعال</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.channel === "email" && (
|
||||||
|
<div><label className={lbl}>قالب ایمیل (اختیاری)</label><input className={inp} placeholder="promotion" value={form.template_code} onChange={(e) => setForm({ ...form, template_code: e.target.value })} /></div>
|
||||||
|
)}
|
||||||
|
{form.channel === "email" && (
|
||||||
|
<div className="sm:col-span-2"><label className={lbl}>موضوع (اگر قالب انتخاب نشده)</label><input className={inp} value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} /></div>
|
||||||
|
)}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className={lbl}>{form.channel === "sms" ? "متن پیامک" : "بدنه HTML (اگر قالب انتخاب نشده)"}</label>
|
||||||
|
<textarea className={`${inp} min-h-[90px]`} value={form.body_html} onChange={(e) => setForm({ ...form, body_html: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3"><button className={btn} onClick={create} disabled={saving || !form.name}>{saving ? "..." : "ساخت کمپین"}</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={`${card} !p-0 overflow-hidden`}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b border-[#1e2235] text-left text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-3">نام</th><th className="px-4 py-3">کانال</th><th className="px-4 py-3">مخاطب</th><th className="px-4 py-3">وضعیت</th><th className="px-4 py-3">ارسال</th><th className="px-4 py-3 text-right">عملیات</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">کمپینی وجود ندارد.</td></tr>
|
||||||
|
) : rows.map((c) => (
|
||||||
|
<tr key={c.id} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||||
|
<td className="px-4 py-3 text-gray-200">{c.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">{c.channel === "sms" ? "پیامک" : "ایمیل"}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">{c.audience}</td>
|
||||||
|
<td className="px-4 py-3">{statusBadge(c.status)}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">{c.sent_count}/{c.total_count}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button className={ghost} disabled={busy === c.id} onClick={() => send(c)}>{busy === c.id ? "در حال ارسال…" : "ارسال"}</button>
|
||||||
|
<button className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10" onClick={() => remove(c)}>حذف</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user