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.
This commit is contained in:
hamed
2026-07-05 11:20:53 +03:30
parent 828e3552c0
commit 969dc9651f
17 changed files with 480 additions and 102 deletions
@@ -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<string,string|int> $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) — ولی هدف این است که همه‌ی تگ‌های سیستمی مقدار داشته باشند.