diff --git a/backend/db/migrations/18_notification_channels.sql b/backend/db/migrations/18_notification_channels.sql new file mode 100644 index 0000000..9b9110d --- /dev/null +++ b/backend/db/migrations/18_notification_channels.sql @@ -0,0 +1,32 @@ +-- ===================================================================== +-- NOTIFICATION SCHEMA — Part 18: channel provider config + seed email templates +-- Stores per-tenant SMS (Kavenegar) and Email (SMTP) provider settings, and seeds +-- the default Persian email templates (welcome / account verification / promotion). +-- ===================================================================== + +SET search_path TO notification, public; + +CREATE TABLE IF NOT EXISTS channel_config ( + tenant_id UUID NOT NULL, + channel TEXT NOT NULL, -- 'sms' | 'email' + settings JSONB NOT NULL DEFAULT '{}',-- kavenegar:{api_key,line_number} / smtp:{host,port,username,password,from_email,from_name,use_tls} + enabled BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, channel) +); + +-- Seed default Email templates (idempotent; no dependency on a unique constraint). +INSERT INTO notification_templates (code, channel, locale, subject, body_html, is_active) +SELECT v.code, v.channel, v.locale, v.subject, v.body_html, TRUE +FROM (VALUES + ('welcome', 'Email', 'fa', 'به فلت‌رندر خوش آمدید 🎉', + '

به فلت‌رندر خوش آمدید!

سلام {{name}}، خوشحالیم که به جمع سازندگان فلت‌رندر پیوستی. حالا می‌تونی با هوش مصنوعی و بیش از ۱٬۲۰۰ قالب، ویدیو و تصویر حرفه‌ای بسازی.

شروع ساخت

اگر این حساب را شما نساخته‌اید، این ایمیل را نادیده بگیرید.

'), + ('account_verification', 'Email', 'fa', 'تأیید حساب کاربری فلت‌رندر', + '

تأیید ایمیل

سلام {{name}}، برای فعال‌سازی حساب خود کد زیر را وارد کنید:

{{code}}

این کد تا ۱۵ دقیقه معتبر است. اگر شما درخواست نداده‌اید، این پیام را نادیده بگیرید.

'), + ('promotion', 'Email', 'fa', 'پیشنهاد ویژهٔ فلت‌رندر ✨', + '

{{title}}

{{body}}

{{cta_text}}

برای لغو دریافت این پیام‌ها از تنظیمات حساب خود اقدام کنید.

') +) AS v(code, channel, locale, subject, body_html) +WHERE NOT EXISTS ( + SELECT 1 FROM notification_templates t + WHERE t.code = v.code AND t.channel = v.channel AND t.locale = v.locale +); diff --git a/messages/en.json b/messages/en.json index fd5d800..73020ab 100644 --- a/messages/en.json +++ b/messages/en.json @@ -323,7 +323,8 @@ "templates": "Templates", "media": "Media", "discounts": "Discounts", - "siteSettings": "Settings" + "siteSettings": "Settings", + "messaging": "Messaging" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index a442a4f..0dfcbaa 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -323,7 +323,8 @@ "templates": "قالب‌ها", "media": "رسانه", "discounts": "تخفیف‌ها", - "siteSettings": "تنظیمات سایت" + "siteSettings": "تنظیمات سایت", + "messaging": "پیام‌رسانی" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index c547f51..2fd9f00 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -139,6 +139,9 @@ func main() { // ── Notification Service ────────────────────────────────────────────────── v1.Any("/notifications/*path", apiRL, auth, notification.Handler()) v1.Any("/notification-templates/*path", apiRL, auth, notification.Handler()) + v1.Any("/channels/*path", apiRL, auth, notification.Handler()) + v1.Any("/sms/*path", apiRL, auth, notification.Handler()) + v1.Any("/email/*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 d1a0faf..cd3da65 100644 --- a/services/notification/cmd/server/main.go +++ b/services/notification/cmd/server/main.go @@ -39,6 +39,7 @@ func main() { notifH := handlers.NewNotificationHandler(store) prefH := handlers.NewPreferenceHandler(store) tplH := handlers.NewTemplateHandler(store) + chH := handlers.NewChannelHandler(store) r := gin.Default() @@ -86,6 +87,12 @@ func main() { v1.GET("/notification-templates", auth, admin, tplH.List) v1.PUT("/notification-templates", auth, admin, tplH.Upsert) + // ── Channels: SMS (Kavenegar) + Email (SMTP) config & send (admin) ──────── + v1.GET("/channels", auth, admin, chH.ListConfigs) + v1.PUT("/channels/:channel", auth, admin, chH.UpsertConfig) + v1.POST("/sms/send", auth, admin, chH.SendSMS) + v1.POST("/email/send", auth, admin, chH.SendEmail) + // ── Internal: create notification (service-to-service) ─────────────────── v1.POST("/internal/notifications", serviceAuth, notifH.CreateInternal) diff --git a/services/notification/internal/db/channels.go b/services/notification/internal/db/channels.go new file mode 100644 index 0000000..a78aac3 --- /dev/null +++ b/services/notification/internal/db/channels.go @@ -0,0 +1,90 @@ +package db + +import ( + "context" + "encoding/json" + + "github.com/flatrender/notification-svc/internal/models" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// ── Channel provider config ────────────────────────────────────────────────── + +func (s *Store) GetChannelConfigs(ctx context.Context, tenantID uuid.UUID) ([]*models.ChannelConfig, error) { + rows, err := s.pool.Query(ctx, + `SELECT tenant_id, channel, settings, enabled, updated_at + FROM notification.channel_config WHERE tenant_id = $1 ORDER BY channel`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*models.ChannelConfig + for rows.Next() { + c := &models.ChannelConfig{} + var raw []byte + if err := rows.Scan(&c.TenantID, &c.Channel, &raw, &c.Enabled, &c.UpdatedAt); err != nil { + return nil, err + } + _ = json.Unmarshal(raw, &c.Settings) + out = append(out, c) + } + return out, rows.Err() +} + +func (s *Store) GetChannelConfig(ctx context.Context, tenantID uuid.UUID, channel string) (*models.ChannelConfig, error) { + c := &models.ChannelConfig{} + var raw []byte + err := s.pool.QueryRow(ctx, + `SELECT tenant_id, channel, settings, enabled, updated_at + FROM notification.channel_config WHERE tenant_id = $1 AND channel = $2`, tenantID, channel). + Scan(&c.TenantID, &c.Channel, &raw, &c.Enabled, &c.UpdatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + _ = json.Unmarshal(raw, &c.Settings) + return c, nil +} + +func (s *Store) UpsertChannelConfig(ctx context.Context, tenantID uuid.UUID, channel string, settings map[string]any, enabled bool) error { + raw, _ := json.Marshal(settings) + _, err := s.pool.Exec(ctx, + `INSERT INTO notification.channel_config (tenant_id, channel, settings, enabled, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (tenant_id, channel) + DO UPDATE SET settings = EXCLUDED.settings, enabled = EXCLUDED.enabled, updated_at = NOW()`, + tenantID, channel, raw, enabled) + return err +} + +// ── Email template lookup ──────────────────────────────────────────────────── + +func (s *Store) GetEmailTemplate(ctx context.Context, code, locale string) (*models.NotificationTemplate, error) { + t := &models.NotificationTemplate{} + err := s.pool.QueryRow(ctx, + `SELECT id, code, channel, locale, subject, body_text, body_html, is_active + FROM notification.notification_templates + WHERE code = $1 AND channel = 'Email' AND locale = $2 AND is_active = TRUE + LIMIT 1`, code, locale). + Scan(&t.ID, &t.Code, &t.Channel, &t.Locale, &t.Subject, &t.BodyText, &t.BodyHTML, &t.IsActive) + if err == pgx.ErrNoRows { + return nil, nil + } + return t, err +} + +// ── Delivery log ───────────────────────────────────────────────────────────── + +func (s *Store) LogDelivery(ctx context.Context, tenantID, userID uuid.UUID, channel, recipient string, + subject, provider, providerMsgID, status, errMsg *string) error { + _, err := s.pool.Exec(ctx, + `INSERT INTO notification.notification_deliveries + (tenant_id, user_id, channel, recipient, subject, provider, provider_message_id, status, error_message, sent_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`, + tenantID, userID, channel, recipient, subject, provider, providerMsgID, status, errMsg) + return err +} diff --git a/services/notification/internal/handlers/channels.go b/services/notification/internal/handlers/channels.go new file mode 100644 index 0000000..83dfaf5 --- /dev/null +++ b/services/notification/internal/handlers/channels.go @@ -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 +} diff --git a/services/notification/internal/models/models.go b/services/notification/internal/models/models.go index f3228ef..b8be6a0 100644 --- a/services/notification/internal/models/models.go +++ b/services/notification/internal/models/models.go @@ -193,3 +193,33 @@ type APIError struct { Code string `json:"code"` Message string `json:"message"` } + +// ── Channel provider config (SMS / Email) ──────────────────────────────────── + +type ChannelConfig struct { + TenantID uuid.UUID `json:"tenant_id"` + Channel string `json:"channel"` // 'sms' | 'email' + Settings map[string]any `json:"settings"` + Enabled bool `json:"enabled"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpsertChannelConfigRequest struct { + Settings map[string]any `json:"settings"` + Enabled *bool `json:"enabled"` +} + +type SendSMSRequest struct { + To string `json:"to" binding:"required"` + Message string `json:"message" binding:"required"` +} + +type SendEmailRequest struct { + To string `json:"to" binding:"required"` + Subject string `json:"subject"` + BodyHTML string `json:"body_html"` + BodyText string `json:"body_text"` + TemplateCode string `json:"template_code"` + Locale string `json:"locale"` + Variables map[string]string `json:"variables"` +} diff --git a/services/notification/internal/sender/sender.go b/services/notification/internal/sender/sender.go new file mode 100644 index 0000000..a1db5b3 --- /dev/null +++ b/services/notification/internal/sender/sender.go @@ -0,0 +1,170 @@ +// Package sender implements the actual SMS (Kavenegar) and Email (SMTP) delivery. +// Stdlib only — keeps the Go build hermetic/vendored. +package sender + +import ( + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/smtp" + "net/url" + "strings" + "time" +) + +// ── SMS via Kavenegar ──────────────────────────────────────────────────────── + +type kavenegarResp struct { + Return struct { + Status int `json:"status"` + Message string `json:"message"` + } `json:"return"` + Entries []struct { + MessageID int64 `json:"messageid"` + Status int `json:"status"` + } `json:"entries"` +} + +// SendSMS sends a message via the Kavenegar REST API. Returns the provider message id. +func SendSMS(apiKey, line, receptor, message string) (string, error) { + if apiKey == "" { + return "", fmt.Errorf("kavenegar api key not configured") + } + endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/sms/send.json", url.PathEscape(apiKey)) + form := url.Values{} + form.Set("receptor", receptor) + form.Set("message", message) + if line != "" { + form.Set("sender", line) + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.PostForm(endpoint, form) + if err != nil { + return "", fmt.Errorf("kavenegar request failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var kr kavenegarResp + if err := json.Unmarshal(body, &kr); err != nil { + return "", fmt.Errorf("kavenegar unexpected response (%d): %s", resp.StatusCode, truncate(string(body), 200)) + } + if kr.Return.Status != 200 { + return "", fmt.Errorf("kavenegar error %d: %s", kr.Return.Status, kr.Return.Message) + } + if len(kr.Entries) > 0 { + return fmt.Sprintf("%d", kr.Entries[0].MessageID), nil + } + return "", nil +} + +// ── Email via SMTP ─────────────────────────────────────────────────────────── + +type SMTPConfig struct { + Host string + Port int + Username string + Password string + FromEmail string + FromName string + UseTLS bool // implicit TLS (e.g. port 465); otherwise STARTTLS is attempted +} + +// SendEmail sends an HTML email. Supports implicit TLS (465) and STARTTLS (587/25). +func SendEmail(cfg SMTPConfig, to, subject, htmlBody string) error { + if cfg.Host == "" { + return fmt.Errorf("smtp host not configured") + } + port := cfg.Port + if port == 0 { + port = 587 + } + from := cfg.FromEmail + if from == "" { + from = cfg.Username + } + fromHeader := from + if cfg.FromName != "" { + fromHeader = fmt.Sprintf("%s <%s>", cfg.FromName, from) + } + + msg := strings.Join([]string{ + "From: " + fromHeader, + "To: " + to, + "Subject: " + mimeEncode(subject), + "MIME-Version: 1.0", + "Content-Type: text/html; charset=UTF-8", + "", + htmlBody, + }, "\r\n") + + addr := fmt.Sprintf("%s:%d", cfg.Host, port) + auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + + // Implicit TLS (port 465 style) + if cfg.UseTLS || port == 465 { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host}) + if err != nil { + return fmt.Errorf("smtp tls dial: %w", err) + } + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() + return sendVia(client, auth, from, to, msg) + } + + // STARTTLS (587/25) — smtp.SendMail upgrades to TLS when the server offers it. + return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg)) +} + +func sendVia(client *smtp.Client, auth smtp.Auth, from, to, msg string) error { + if ok, _ := client.Extension("AUTH"); ok { + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + if err := client.Mail(from); err != nil { + return err + } + if err := client.Rcpt(to); err != nil { + return err + } + w, err := client.Data() + if err != nil { + return err + } + if _, err := w.Write([]byte(msg)); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + return client.Quit() +} + +func mimeEncode(s string) string { + // RFC 2047 encoded-word for non-ASCII subjects (Persian). + for _, r := range s { + if r > 127 { + return "=?UTF-8?B?" + base64Std(s) + "?=" + } + } + return s +} + +func base64Std(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index d923db8..1d520cf 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -24,6 +24,7 @@ export default async function AdminLayout({ { href: "/admin/slides", label: t("slides") }, { href: "/admin/files", label: t("media") }, { href: "/admin/ai", label: t("aiContent") }, + { href: "/admin/messaging", label: t("messaging") }, { 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/messaging/page.tsx b/src/app/[locale]/admin/messaging/page.tsx new file mode 100644 index 0000000..f53517b --- /dev/null +++ b/src/app/[locale]/admin/messaging/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { MessagingAdmin } from "@/components/admin/MessagingAdmin"; + +export default function Page() { + return ; +} diff --git a/src/components/admin/MessagingAdmin.tsx b/src/components/admin/MessagingAdmin.tsx new file mode 100644 index 0000000..4b8d043 --- /dev/null +++ b/src/components/admin/MessagingAdmin.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +interface ChannelCfg { channel: string; enabled: boolean; settings: Record } +interface Tpl { id?: string; code: string; channel: string; locale: string; subject?: string | null; body_html?: string | null; is_active?: boolean } + +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 s = (v: unknown) => (v == null ? "" : String(v)); + +export function MessagingAdmin() { + const [sms, setSms] = useState({ channel: "sms", enabled: false, settings: {} }); + const [email, setEmail] = useState({ channel: "email", enabled: false, settings: {} }); + const [tpls, setTpls] = useState([]); + const [editTpl, setEditTpl] = useState(null); + const [msg, setMsg] = useState>({}); + + const setS = (k: string, v: unknown) => setSms((c) => ({ ...c, settings: { ...c.settings, [k]: v } })); + const setE = (k: string, v: unknown) => setEmail((c) => ({ ...c, settings: { ...c.settings, [k]: v } })); + const flash = (k: string, t: string) => { setMsg((m) => ({ ...m, [k]: t })); setTimeout(() => setMsg((m) => ({ ...m, [k]: "" })), 2500); }; + + const reload = useCallback(async () => { + const ch = await fetch("/api/admin/resource/channels", { cache: "no-store" }).then((r) => r.json()).catch(() => null); + if (ch?.sms) setSms({ channel: "sms", enabled: !!ch.sms.enabled, settings: ch.sms.settings ?? {} }); + if (ch?.email) setEmail({ channel: "email", enabled: !!ch.email.enabled, settings: ch.email.settings ?? {} }); + const t = await fetch("/api/admin/resource/notification-templates", { cache: "no-store" }).then((r) => r.json()).catch(() => null); + const list: Tpl[] = Array.isArray(t) ? t : t?.data ?? t?.items ?? []; + setTpls(list.filter((x) => x.channel === "Email")); + }, []); + useEffect(() => { reload(); }, [reload]); + + const saveChannel = async (ch: "sms" | "email", cfg: ChannelCfg) => { + const res = await fetch(`/api/admin/resource/channels/${ch}`, { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: cfg.settings, enabled: cfg.enabled }), + }); + flash(ch, res.ok ? "ذخیره شد ✓" : "خطا در ذخیره"); + if (res.ok) reload(); + }; + + const sendTest = async (ch: "sms" | "email", body: object) => { + const res = await fetch(`/api/admin/resource/${ch}/send`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + const d = await res.json().catch(() => null); + flash(ch + "_send", res.ok ? "ارسال شد ✓" : (d?.error ?? "ارسال ناموفق")); + }; + + const saveTpl = async () => { + if (!editTpl) return; + const res = await fetch("/api/admin/resource/notification-templates", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: editTpl.code, channel: "Email", locale: editTpl.locale || "fa", + subject: editTpl.subject, body_html: editTpl.body_html, is_active: editTpl.is_active ?? true, + }), + }); + flash("tpl", res.ok ? "قالب ذخیره شد ✓" : "خطا"); + if (res.ok) { setEditTpl(null); reload(); } + }; + + const [smsTo, setSmsTo] = useState(""); const [smsMsg, setSmsMsg] = useState(""); + const [emTo, setEmTo] = useState(""); const [emSub, setEmSub] = useState(""); const [emBody, setEmBody] = useState(""); + + return ( +
+
+

Messaging — SMS & Email

+

Configure providers, send test messages, and edit email templates. Enter your real keys when ready.

+
+ + {/* SMS / Kavenegar */} +
+
+

SMS · Kavenegar

+ +
+
+
setS("api_key", e.target.value)} />
+
setS("line_number", e.target.value)} />
+
+
+ + {msg.sms && {msg.sms}} +
+
+ +
+ setSmsTo(e.target.value)} /> + setSmsMsg(e.target.value)} /> + +
+ {msg.sms_send && {msg.sms_send}} +
+
+ + {/* Email / SMTP */} +
+
+

Email · SMTP

+ +
+
+
setE("host", e.target.value)} placeholder="smtp.example.com" />
+
setE("port", Number(e.target.value))} placeholder="587" />
+
setE("username", e.target.value)} />
+
setE("password", e.target.value)} />
+
setE("from_email", e.target.value)} placeholder="no-reply@flatrender.com" />
+
setE("from_name", e.target.value)} placeholder="FlatRender" />
+ +
+
+ + {msg.email && {msg.email}} +
+
+ +
+
+ setEmTo(e.target.value)} /> + setEmSub(e.target.value)} /> +
+