feat(notifications+admin): SMS (Kavenegar) + Email (SMTP) channels & templates
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>
This commit is contained in:
soroush.asadi
2026-06-02 17:32:54 +03:30
parent e3f025cd2d
commit 507ac7e6a4
12 changed files with 735 additions and 2 deletions
+1
View File
@@ -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") },
@@ -0,0 +1,7 @@
"use client";
import { MessagingAdmin } from "@/components/admin/MessagingAdmin";
export default function Page() {
return <MessagingAdmin />;
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useCallback, useEffect, useState } from "react";
interface ChannelCfg { channel: string; enabled: boolean; settings: Record<string, unknown> }
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<ChannelCfg>({ channel: "sms", enabled: false, settings: {} });
const [email, setEmail] = useState<ChannelCfg>({ channel: "email", enabled: false, settings: {} });
const [tpls, setTpls] = useState<Tpl[]>([]);
const [editTpl, setEditTpl] = useState<Tpl | null>(null);
const [msg, setMsg] = useState<Record<string, string>>({});
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 (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-white">Messaging SMS & Email</h1>
<p className="mt-1 text-sm text-gray-400">Configure providers, send test messages, and edit email templates. Enter your real keys when ready.</p>
</div>
{/* SMS / Kavenegar */}
<section className={card}>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">SMS · Kavenegar</h2>
<label className="flex items-center gap-2 text-sm text-gray-300"><input type="checkbox" checked={sms.enabled} onChange={(e) => setSms({ ...sms, enabled: e.target.checked })} /> فعال</label>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div><label className={lbl}>API Key (توکن کاوهنگار)</label><input className={inp} type="password" placeholder={s(sms.settings.api_key) || "API key"} value={s(sms.settings.api_key).includes("•") ? "" : s(sms.settings.api_key)} onChange={(e) => setS("api_key", e.target.value)} /></div>
<div><label className={lbl}>Line number (شماره خط)</label><input className={inp} value={s(sms.settings.line_number)} onChange={(e) => setS("line_number", e.target.value)} /></div>
</div>
<div className="mt-3 flex items-center gap-3">
<button className={btn} onClick={() => saveChannel("sms", sms)}>ذخیره تنظیمات SMS</button>
{msg.sms && <span className="text-xs text-gray-400">{msg.sms}</span>}
</div>
<div className="mt-4 border-t border-[#1e2235] pt-4">
<label className={lbl}>ارسال آزمایشی</label>
<div className="flex flex-wrap gap-2">
<input className={`${inp} max-w-[180px]`} placeholder="شماره گیرنده" value={smsTo} onChange={(e) => setSmsTo(e.target.value)} />
<input className={`${inp} flex-1`} placeholder="متن پیامک" value={smsMsg} onChange={(e) => setSmsMsg(e.target.value)} />
<button className={ghost} disabled={!smsTo || !smsMsg} onClick={() => sendTest("sms", { to: smsTo, message: smsMsg })}>ارسال</button>
</div>
{msg.sms_send && <span className="mt-2 block text-xs text-gray-400">{msg.sms_send}</span>}
</div>
</section>
{/* Email / SMTP */}
<section className={card}>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Email · SMTP</h2>
<label className="flex items-center gap-2 text-sm text-gray-300"><input type="checkbox" checked={email.enabled} onChange={(e) => setEmail({ ...email, enabled: e.target.checked })} /> فعال</label>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div><label className={lbl}>SMTP Host</label><input className={inp} value={s(email.settings.host)} onChange={(e) => setE("host", e.target.value)} placeholder="smtp.example.com" /></div>
<div><label className={lbl}>Port</label><input className={inp} type="number" value={s(email.settings.port)} onChange={(e) => setE("port", Number(e.target.value))} placeholder="587" /></div>
<div><label className={lbl}>Username</label><input className={inp} value={s(email.settings.username)} onChange={(e) => setE("username", e.target.value)} /></div>
<div><label className={lbl}>Password</label><input className={inp} type="password" placeholder={s(email.settings.password) || "password"} value={s(email.settings.password).includes("•") ? "" : s(email.settings.password)} onChange={(e) => setE("password", e.target.value)} /></div>
<div><label className={lbl}>From email</label><input className={inp} value={s(email.settings.from_email)} onChange={(e) => setE("from_email", e.target.value)} placeholder="no-reply@flatrender.com" /></div>
<div><label className={lbl}>From name</label><input className={inp} value={s(email.settings.from_name)} onChange={(e) => setE("from_name", e.target.value)} placeholder="FlatRender" /></div>
<label className="flex items-center gap-2 text-sm text-gray-300"><input type="checkbox" checked={!!email.settings.use_tls} onChange={(e) => setE("use_tls", e.target.checked)} /> TLS مستقیم (پورت ۴۶۵)</label>
</div>
<div className="mt-3 flex items-center gap-3">
<button className={btn} onClick={() => saveChannel("email", email)}>ذخیره تنظیمات Email</button>
{msg.email && <span className="text-xs text-gray-400">{msg.email}</span>}
</div>
<div className="mt-4 border-t border-[#1e2235] pt-4">
<label className={lbl}>ارسال آزمایشی</label>
<div className="grid gap-2">
<div className="flex flex-wrap gap-2">
<input className={`${inp} max-w-[240px]`} placeholder="ایمیل گیرنده" value={emTo} onChange={(e) => setEmTo(e.target.value)} />
<input className={`${inp} flex-1`} placeholder="موضوع" value={emSub} onChange={(e) => setEmSub(e.target.value)} />
</div>
<textarea className={`${inp} min-h-[70px]`} placeholder="<p>متن HTML ایمیل</p>" value={emBody} onChange={(e) => setEmBody(e.target.value)} />
<div><button className={ghost} disabled={!emTo} onClick={() => sendTest("email", { to: emTo, subject: emSub, body_html: emBody })}>ارسال ایمیل آزمایشی</button>
{msg.email_send && <span className="ms-2 text-xs text-gray-400">{msg.email_send}</span>}</div>
</div>
</div>
</section>
{/* Email templates */}
<section className={card}>
<h2 className="text-sm font-semibold text-white">قالبهای ایمیل</h2>
<p className="mt-1 text-xs text-gray-500">قالبهای welcome / account_verification / promotion. متغیرها بهصورت {"{{name}}"} نوشته میشوند.</p>
<div className="mt-4 overflow-hidden rounded-lg border border-[#262b40]">
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2235] text-left text-xs text-gray-500"><th className="px-3 py-2">Code</th><th className="px-3 py-2">Subject</th><th className="px-3 py-2">Locale</th><th className="px-3 py-2 text-right"></th></tr></thead>
<tbody>
{tpls.length === 0 ? (
<tr><td colSpan={4} className="px-3 py-6 text-center text-gray-500">قالبی یافت نشد.</td></tr>
) : tpls.map((t) => (
<tr key={t.code + t.locale} className="border-b border-[#161a2e]">
<td className="px-3 py-2 font-mono text-xs text-gray-200">{t.code}</td>
<td className="px-3 py-2 text-gray-300">{t.subject}</td>
<td className="px-3 py-2 text-gray-500">{t.locale}</td>
<td className="px-3 py-2 text-right"><button className={ghost} onClick={() => setEditTpl({ ...t })}>ویرایش</button></td>
</tr>
))}
</tbody>
</table>
</div>
{msg.tpl && <span className="mt-2 block text-xs text-gray-400">{msg.tpl}</span>}
</section>
{editTpl && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setEditTpl(null)}>
<div className={`${card} w-full max-w-2xl`} onClick={(e) => e.stopPropagation()}>
<h2 className="text-sm font-semibold text-white">ویرایش قالب: {editTpl.code}</h2>
<div className="mt-4 grid gap-3">
<div><label className={lbl}>موضوع</label><input className={inp} value={editTpl.subject ?? ""} onChange={(e) => setEditTpl({ ...editTpl, subject: e.target.value })} /></div>
<div><label className={lbl}>بدنه HTML</label><textarea className={`${inp} min-h-[220px] font-mono text-xs`} value={editTpl.body_html ?? ""} onChange={(e) => setEditTpl({ ...editTpl, body_html: e.target.value })} /></div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button className={ghost} onClick={() => setEditTpl(null)}>انصراف</button>
<button className={btn} onClick={saveTpl}>ذخیره قالب</button>
</div>
</div>
</div>
)}
</div>
);
}