From 969dc9651f5bad5688332d1eabe63e154e06abe4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 5 Jul 2026 11:20:53 +0330 Subject: [PATCH] Refactor SMS sending to use KavehNegar VerifyLookup templates - Removed SmsTextResolver dependency from multiple services and controllers. - Introduced dispatchTemplate method in SmsService to handle SMS sending with templates. - Updated existing SMS sending logic across various services (OtpService, PreRegistrationController, ClinicInvitationService, PaymentManager, SecretaryController, RepresentationActionController) to utilize the new dispatchTemplate method. - Enhanced SmsMessageTemplate entity to include kavenegar_template and token_map fields. - Created migration to add new fields to the sms_message_templates table and populate them with existing data. - Updated SeedSmsMessageTemplatesCommand to handle new template structure. - Added documentation for the new SMS template structure and usage. --- .claude/prompt/sms-verify-lookup-templates.md | 212 ++++++++++++++++++ assets/admin/pages/SmsPage.tsx | 30 ++- assets/admin/types/index.ts | 2 + config/services.yaml | 1 - docs/api/sms.md | 35 ++- migrations/Version20260705072835.php | 51 +++++ .../NotificationMobileController.php | 10 +- .../Controller/PreRegistrationController.php | 16 +- src/Auth/Service/OtpService.php | 20 +- .../Service/ClinicInvitationService.php | 5 +- src/Payment/Service/PaymentManager.php | 10 +- .../RepresentationActionController.php | 12 +- .../Controller/SecretaryController.php | 6 +- .../SeedSmsMessageTemplatesCommand.php | 20 +- src/Sms/Controller/SmsMessageController.php | 28 ++- src/Sms/Entity/SmsMessageTemplate.php | 81 +++++-- src/Sms/Service/SmsService.php | 43 +++- 17 files changed, 480 insertions(+), 102 deletions(-) create mode 100644 .claude/prompt/sms-verify-lookup-templates.md create mode 100644 migrations/Version20260705072835.php diff --git a/.claude/prompt/sms-verify-lookup-templates.md b/.claude/prompt/sms-verify-lookup-templates.md new file mode 100644 index 00000000..29fa9ed2 --- /dev/null +++ b/.claude/prompt/sms-verify-lookup-templates.md @@ -0,0 +1,212 @@ +# تبدیل همه‌ی پیامک‌های سیستمی به VerifyLookup کاوه‌نگار (تمپلت نام‌دار) + +## پروژه + +`clinicpro` (backend `src/Sms` + سرویس‌های فرستنده + پنل ادمین `assets/admin/pages/SmsPage.tsx`) + +## زمینه + +همه‌ی پیامک‌های ما «اطلاع‌رسانی/تراکنشی» هستند. سرویس کاوه‌نگار برای این نوع پیامک‌ها **VerifyLookup** را الزام می‌کند (نه ارسال متن آزاد `sms/send`). VerifyLookup فقط با تمپلت‌های **از پیش‌ساخته و تأییدشده در پنل کاوه‌نگار** کار می‌کند و متغیرها را در جایگاه‌های `token`, `token2`, `token3`, `token10`, `token20` جایگذاری می‌کند. + +- مستند REST: `https://kavenegar.com/rest.html#sms-Lookup` +- مستند SDK: `https://kavenegar.com/sdk.html#php` + +وضعیت فعلی کد: `KavehNegarProvider` **از قبل هر دو متد را دارد** — `send()` (متن آزاد via `sms/send.json`) و `sendTemplate()` (VerifyLookup via `verify/lookup.json`). اما **فقط OTP** از lookup استفاده می‌کند؛ بقیه‌ی همه‌ی پیامک‌ها با `send()` (متن آزاد) می‌روند. همچنین موجودیت `SmsMessageTemplate` (متن ویرایش‌پذیر هر تگ) **نه نام تمپلت کاوه‌نگار دارد نه نگاشت متغیر→token**. + +## مشکل / هدف + +۱. همه‌ی پیامک‌های سیستمی (همه‌ی تگ‌ها) باید از طریق **VerifyLookup** ارسال شوند، نه `send()` متن‌آزاد. +۲. هر تمپلت سیستمی باید یک **نام تمپلت کاوه‌نگار** داشته باشد (که کاربر در پنل کاوه‌نگار می‌سازد) + یک **نگاشت متغیر→token slot**. +۳. مسیر فرستنده متمرکز شود تا هر call-site فقط تگ + متغیرها بدهد و ارسال همیشه lookup باشد. + +## قید حیاتی VerifyLookup (حتماً رعایت شود) + +- تمپلت باید از قبل در پنل کاوه‌نگار ساخته و **تأیید** شده باشد؛ متن ثابت پیام در پنل تعریف می‌شود، نه در دیتابیس ما. یعنی بعد از این تغییر، **متن واقعی ارسالی = تمپلت کاوه‌نگار**؛ فیلد `body` در DB فقط برای پیش‌نمایش ادمین و متن `SmsLog` می‌ماند و باید دستی با تمپلت پنل هم‌راستا نگه داشته شود. +- جایگاه‌ها: `token`, `token2`, `token3` → **فاصله (space) نمی‌پذیرند** (تک‌مقدار بدون space)؛ `token10` و `token20` → فاصله مجازند. پس هر مقداری که ممکن است space داشته باشد (نام دکتر/بیمار/کلینیک/مالک/نام سایت) باید در `token10`/`token20` برود؛ مقادیر بدون space (کد، تاریخ `۱۴۰۳/۰۵/۱۲`، ساعت، username، password، لینک) در `token`/`token2`/`token3`. +- newline/متن طولانی داخل token مجاز نیست؛ خطوط ثابت و شکست خط باید داخل خودِ تمپلت پنل باشند، فقط مقادیر متغیر token شوند. +- `KavehNegarProvider::sendTemplate()` از قبل نگاشت صریح slot را پشتیبانی می‌کند (اگر کلیدهای `$vars` دقیقاً نام slotها باشند از همان استفاده می‌کند)، پس منطق provider نیاز به تغییر ندارد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Sms/Provider/KavehNegarProvider.php` | `sendTemplate()` (VerifyLookup) — **آماده است، تغییر نده** | +| `src/Sms/Service/SmsService.php` | `dispatchAsync()` / `sendNow()` — افزودن متد متمرکز `dispatchTemplate()` | +| `src/Sms/Service/SmsTextResolver.php` | resolve متن body برای log/preview | +| `src/Sms/Entity/SmsMessageTemplate.php` | افزودن `kavenegar_template` + `token_map` به فیلدها و `DEFAULTS` | +| `src/Sms/Command/SeedSmsMessageTemplatesCommand.php` | seed از `DEFAULTS` | +| `src/Sms/Controller/SmsMessageController.php` | GET/PATCH تمپلت‌های سیستمی (ادمین) | +| `assets/admin/pages/SmsPage.tsx` | ویرایش تمپلت‌های سیستمی | +| `src/Auth/Service/OtpService.php` | OTP (تنها جایی که الان lookup می‌کند) | +| `src/Auth/Controller/NotificationMobileController.php` | تگ `notification_mobile` | +| `src/Auth/Controller/PreRegistrationController.php` | تگ `pre_registration` | +| `src/Secretary/Controller/SecretaryController.php` | تگ `secretary` | +| `src/Payment/Service/PaymentManager.php` | تگ‌های `payment` و `doctor_appointment` | +| `src/ClinicInvitation/Service/ClinicInvitationService.php` | تگ `clinic_invitation` | +| `src/Representation/Controller/RepresentationActionController.php` | تگ `welcome` (الان `sprintf` inline) | +| `migrations/VersionXXithm.php` | migration برای دو ستون جدید | +| `docs/api/sms.md` | مستندسازی | + +## وضعیت فعلی + +### `SmsService::sendNow` — انتخاب بین lookup و متن‌آزاد + +```php +$success = ($msg->templateCode !== null) + ? $provider->sendTemplate($msg->mobile, $msg->templateCode, $msg->templateVars) + : $provider->send($msg->mobile, $msg->message); +``` + +### الگوی فعلی همه‌ی call-siteها (به‌جز OTP) — متن‌آزاد، بدون `templateCode` + +```php +// PaymentManager.php:318 +$message = $this->smsText->resolve(SmsLog::TAG_PAYMENT, ['doctor' => $doctor, 'date' => $date]); +$this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_PAYMENT); +// ClinicInvitationService.php:116، SecretaryController.php:133، NotificationMobileController.php:64، +// PreRegistrationController.php:158 — همگی همین شکل: resolve(...) سپس dispatchAsync(mobile, message, tag: TAG) +``` + +### تنها جای درست (OTP) — که باید الگوی بقیه شود + +```php +// OtpService.php:80 +$this->sms->dispatchAsync( + $mobile, $message, + templateCode: $this->otpTemplate, // نام تمپلت کاوه‌نگار از env + templateVars: ['token' => $code, 'token10' => $site], // نگاشت صریح slot + tag: SmsLog::TAG_OTP, +); +``` + +### `SmsMessageTemplate::DEFAULTS` (فاقد نام تمپلت و نگاشت token) + +```php +SmsLog::TAG_PAYMENT => [ + 'title' => 'تأیید پرداخت و نوبت', + 'body' => 'نوبت شما با {doctor} در تاریخ {date} ثبت و تأیید شد.', + 'variables' => ['doctor', 'date'], +], +// ... بقیه‌ی تگ‌ها مشابه +``` + +### `RepresentationActionController` — welcome به‌صورت inline (بدون تمپلت) + +```php +$this->smsService->dispatchAsync( + $mobile, + sprintf('دکتر %s عزیز، به %s خوش آمدید. ...', $name, $siteName), + tag: \App\Sms\Entity\SmsLog::TAG_WELCOME, +); +``` + +## وظایف + +### ۱. افزودن `kavenegar_template` و `token_map` به `SmsMessageTemplate` + `DEFAULTS` + +در `src/Sms/Entity/SmsMessageTemplate.php`: + +- دو ستون جدید: +```php +#[ORM\Column(name: 'kavenegar_template', type: 'string', length: 100, nullable: true)] +private ?string $kavenegarTemplate = null; + +// نگاشت متغیر منطقی → slot کاوه‌نگار: مثلاً {"code":"token","site":"token10"} +#[ORM\Column(name: 'token_map', type: 'json')] +private array $tokenMap = []; +``` +- getter/setter، افزودن به constructor و `toArray()`. +- در هر آیتم `DEFAULTS` دو کلید اضافه کن: `kavenegar_template` و `token_map`. مقادیر پیشنهادی (با رعایت قید space): + +| تگ | نام تمپلت کاوه‌نگار (پیشنهادی) | token_map | +|----|-------------------------------|-----------| +| `otp` | `clinicpro-otp` | `{ "code": "token", "site": "token10" }` | +| `notification_mobile` | `clinicpro-notify-code` | `{ "code": "token" }` | +| `payment` | `clinicpro-payment` | `{ "doctor": "token10", "date": "token2" }` | +| `clinic_invitation` | `clinicpro-clinic-invite` | `{ "clinic": "token10", "link": "token" }` | +| `pre_registration` | `clinicpro-pre-register` | `{ "username": "token", "password": "token2", "link": "token3" }` | +| `secretary` | `clinicpro-secretary` | `{ "owner": "token10", "username": "token", "link": "token3" }` | +| `doctor_appointment` | `clinicpro-doctor-appt` | `{ "patient": "token10", "date": "token2", "time": "token3" }` | +| `welcome` | `clinicpro-welcome` | `{ "name": "token10", "site": "token20" }` | + +> نام‌ها را نهایی با کاربر چک کن؛ همین نام‌ها باید در پنل کاوه‌نگار ساخته شوند (وظیفه‌ی ۶). + +- سپس migration بساز (`ddev exec php bin/console doctrine:migrations:diff`) و seed را طوری کن که رکوردهای موجود هم دو ستون جدید را بگیرند (در `SeedSmsMessageTemplatesCommand` علاوه بر ساخت رکورد جدید، اگر رکورد هست ولی `kavenegar_template` خالی است، از `DEFAULTS` پرش کن — یا یک migration داده‌ای `UPDATE` برای پرکردن مقادیر). + +### ۲. متد متمرکز `SmsService::dispatchTemplate(tag, mobile, vars)` + +یک متد واحد که همه‌ی call-siteها از آن استفاده کنند: + +```php +/** @param array $vars متغیرهای منطقی (مثل ['doctor'=>..,'date'=>..]) */ +public function dispatchTemplate(string $tag, string $mobile, array $vars = []): void +{ + $tpl = $this->messageTemplateRepo->findByTag($tag); + $kaveTemplate = $tpl?->getKavenegarTemplate() + ?? SmsMessageTemplate::DEFAULTS[$tag]['kavenegar_template'] ?? null; + $tokenMap = $tpl?->getTokenMap() + ?: (SmsMessageTemplate::DEFAULTS[$tag]['token_map'] ?? []); + + // متن body برای log/preview (تمپلت واقعی سمت کاوه‌نگار است) + $message = $this->textResolver->resolve($tag, $vars); + + if ($kaveTemplate !== null && $tokenMap !== []) { + // نگاشت متغیر منطقی → slot کاوه‌نگار + $slotVars = []; + foreach ($tokenMap as $logicalKey => $slot) { + if (array_key_exists($logicalKey, $vars)) { + $slotVars[$slot] = (string) $vars[$logicalKey]; + } + } + $this->dispatchAsync($mobile, $message, templateCode: $kaveTemplate, templateVars: $slotVars, tag: $tag); + } else { + // fallback فقط اگر تمپلت کاوه‌نگار تعریف نشده باشد + $this->dispatchAsync($mobile, $message, tag: $tag); + } +} +``` + +- `SmsService` باید `SmsMessageTemplateRepository` و `SmsTextResolver` را inject کند. +- **قید space را رعایت کن:** اگر مقداری که به `token`/`token2`/`token3` می‌رود شامل space باشد، کاوه‌نگار خطا می‌دهد. token_map در `DEFAULTS` طوری چیده شده که مقادیر دارای space در `token10`/`token20` بروند؛ هنگام افزودن تگ جدید همین را رعایت کن. + +### ۳. مهاجرت همه‌ی call-siteها به `dispatchTemplate` + +هر جای زیر را از الگوی «`resolve()` + `dispatchAsync(..., tag:)`» به یک فراخوانی `dispatchTemplate(tag, mobile, vars)` تبدیل کن (متغیرهای منطقی همان کلیدهای `{...}` بدنه‌اند): + +- `src/Auth/Service/OtpService.php:80` — `dispatchTemplate(TAG_OTP, $mobile, ['code'=>$code,'site'=>$site])` (شاخه‌ی env `otpTemplate` و fallback حذف شود؛ منبع نام تمپلت حالا DB/DEFAULTS است). +- `src/Auth/Controller/NotificationMobileController.php:64` +- `src/Auth/Controller/PreRegistrationController.php:158` +- `src/Secretary/Controller/SecretaryController.php:133` +- `src/Payment/Service/PaymentManager.php:318` (payment) و `:328` (doctor_appointment) +- `src/ClinicInvitation/Service/ClinicInvitationService.php:116` + +### ۴. تگ `welcome` را از inline به تمپلت تبدیل کن + +در `src/Representation/Controller/RepresentationActionController.php` (دو جای ~313 و ~388) به‌جای `sprintf(...)` از `dispatchTemplate(TAG_WELCOME, $mobile, ['name'=>$name,'site'=>$siteName])` استفاده کن. مطمئن شو `TAG_WELCOME` در `DEFAULTS` وجود دارد (در وظیفه‌ی ۱ اضافه شد). + +### ۵. پنل ادمین: نمایش/ویرایش نام تمپلت کاوه‌نگار + +- `src/Sms/Controller/SmsMessageController.php`: در پاسخ GET، `kavenegar_template` و `token_map` را هم برگردان (از `toArray()`)؛ در PATCH اجازه‌ی ویرایش `kavenegar_template` (و در صورت لزوم `token_map`) را بده. +- `assets/admin/pages/SmsPage.tsx`: فیلد «نام تمپلت کاوه‌نگار» را در فرم ویرایش هر تمپلت سیستمی نشان بده و ذخیره کن. یک راهنمای کوتاه بگذار که این نام باید دقیقاً با تمپلت ساخته‌شده در پنل کاوه‌نگار یکی باشد. +- **مستند API** (`docs/api/sms.md`): تغییر response/بدنه‌ی `admin/sms/messages` را ثبت کن. + +### ۶. فهرست تمپلت‌هایی که کاربر باید در پنل کاوه‌نگار بسازد + +در پایان، این جدول را (با نام‌های نهایی) به کاربر بده تا در پنل کاوه‌نگار بسازد؛ متن هر تمپلت باید با `body` همان تگ هم‌راستا باشد و جایگاه‌ها با `%token...` مطابق token_map: + +| تمپلت | نمونه متن پنل (جایگاه‌ها با token_map) | +|-------|----------------------------------------| +| `clinicpro-otp` | `کد تأیید شما: %token` (سایت: `%token10`) | +| `clinicpro-payment` | `نوبت شما با %token10 در تاریخ %token2 ثبت و تأیید شد.` | +| … | (برای هر تگ بر اساس body و token_map) | + +## نکات مهم + +- **متن واقعی ارسالی از پنل کاوه‌نگار می‌آید**، نه از `body` دیتابیس. پس `body` را فقط برای preview/log نگه‌دار و در پنل هم همان متن را بساز؛ اگر ادمین `body` را عوض کند، متن ارسالی عوض نمی‌شود مگر تمپلت پنل هم عوض شود — این را در UI به ادمین گوشزد کن. +- **قید space در token/token2/token3** مهم‌ترین علت خطای ۴۳۱/۴۱۸ کاوه‌نگار است؛ مقادیر دارای فاصله را حتماً به `token10`/`token20` بده (token_map پیش‌فرض این را رعایت کرده). +- **لینک‌ها**: بعضی تمپلت‌های VerifyLookup لینک را فقط اگر تمپلت با لینک تأیید شده باشد می‌پذیرند؛ برای تگ‌های دارای `{link}` (clinic_invitation, pre_registration, secretary) هنگام ساخت تمپلت در پنل، تأیید لینک را بگیر. +- **`SDK` کاوه‌نگار (`kavenegar/php`) لازم نیست**: provider فعلی مستقیم `verify/lookup.json` را با `HttpClient` صدا می‌زند و درست است. اگر کاربر صراحتاً SDK بخواهد، می‌توان `composer require kavenegar/php` کرد و provider را بازنویسی کرد، ولی پیش‌فرض همین HttpClient بماند (بدون وابستگی جدید). +- **`TAG_USER_TEMPLATE` (پنل پیامک کلینیک‌ها، `SmsController`)**: این‌ها پیامک‌های متن‌آزادِ کاربرساخته با `SmsTemplate.providerCode` هستند و از قبل مسیر تمپلت‌دار دارند؛ **در دامنه‌ی این تغییر نیستند**. اگر کاربر می‌خواهد آن‌ها هم اجباری lookup شوند، جداگانه بپرس (متن آزاد کاربر با VerifyLookup سازگار نیست مگر هر متن یک تمپلت تأییدشده داشته باشد). +- **`env KAVENEGAR_OTP_TEMPLATE`**: بعد از انتقال نام تمپلت به DB، این env و binding `$otpTemplate` در `config/services.yaml:59` را حذف یا به fallback تبدیل کن (تا جای واحدِ حقیقت، DB باشد). +- بعد از تغییر Entity: `doctrine:migrations:diff` سپس `migrate`. بعد از تغییر API: `docs/api/sms.md`. تست: `ddev exec php bin/console app:seed-sms-templates` (یا نام واقعی seed) و بررسی ارسال با یک تگ (مثلاً OTP) در محیط تست. +- edge case: اگر تگی `token_map` نداشت یا `kavenegar_template` خالی بود، `dispatchTemplate` باید به‌صورت امن fallback کند (نه crash) — ولی هدف این است که همه‌ی تگ‌های سیستمی مقدار داشته باشند. diff --git a/assets/admin/pages/SmsPage.tsx b/assets/admin/pages/SmsPage.tsx index 9ee04327..41fdcd00 100644 --- a/assets/admin/pages/SmsPage.tsx +++ b/assets/admin/pages/SmsPage.tsx @@ -60,6 +60,7 @@ export default function SmsPage() { const [viewLog, setViewLog] = useState(null); const [editMsg, setEditMsg] = useState(null); const [editBody, setEditBody] = useState(''); + const [editKaveName, setEditKaveName] = useState(''); const [editTemplate, setEditTemplate] = useState(null); const [editTplName, setEditTplName] = useState(''); const [editTplBody, setEditTplBody] = useState(''); @@ -98,8 +99,8 @@ export default function SmsPage() { }); const updateMessageMut = useMutation({ - mutationFn: ({ tag, body }: { tag: string; body: string }) => - api.patch>(`/api/v1/admin/sms/messages/${tag}`, { body }), + mutationFn: ({ tag, body, kavenegarTemplate }: { tag: string; body: string; kavenegarTemplate: string }) => + api.patch>(`/api/v1/admin/sms/messages/${tag}`, { body, kavenegar_template: kavenegarTemplate }), onSuccess: () => { toast.success('متن پیامک به‌روزرسانی شد'); setEditMsg(null); @@ -485,11 +486,17 @@ export default function SmsPage() {
{m.title} -
{m.body}
+
+ تمپلت کاوه‌نگار: + + {m.kavenegar_template || 'تعریف‌نشده'} + +
{m.variables.length > 0 && (
{m.variables.map((v) => {`{${v}}`})} @@ -513,7 +520,7 @@ export default function SmsPage() { @@ -521,13 +528,22 @@ export default function SmsPage() { } >
- +