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:
@@ -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) — ولی هدف این است که همهی تگهای سیستمی مقدار داشته باشند.
|
||||
@@ -60,6 +60,7 @@ export default function SmsPage() {
|
||||
const [viewLog, setViewLog] = useState<SmsLog | null>(null);
|
||||
const [editMsg, setEditMsg] = useState<SmsMessageText | null>(null);
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [editKaveName, setEditKaveName] = useState('');
|
||||
const [editTemplate, setEditTemplate] = useState<SmsTemplate | null>(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<ApiResponse<SmsMessageText>>(`/api/v1/admin/sms/messages/${tag}`, { body }),
|
||||
mutationFn: ({ tag, body, kavenegarTemplate }: { tag: string; body: string; kavenegarTemplate: string }) =>
|
||||
api.patch<ApiResponse<SmsMessageText>>(`/api/v1/admin/sms/messages/${tag}`, { body, kavenegar_template: kavenegarTemplate }),
|
||||
onSuccess: () => {
|
||||
toast.success('متن پیامک بهروزرسانی شد');
|
||||
setEditMsg(null);
|
||||
@@ -485,11 +486,17 @@ export default function SmsPage() {
|
||||
<div key={m.tag} className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<b style={{ fontSize: 14 }}>{m.title}</b>
|
||||
<button className="btn ghost sm" onClick={() => { setEditMsg(m); setEditBody(m.body); }}>
|
||||
<button className="btn ghost sm" onClick={() => { setEditMsg(m); setEditBody(m.body); setEditKaveName(m.kavenegar_template ?? ''); }}>
|
||||
<PencilIcon style={{ width: 14, height: 14 }} /> ویرایش
|
||||
</button>
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: 13, lineHeight: 1.9, whiteSpace: 'pre-wrap', direction: 'rtl' }}>{m.body}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="muted" style={{ fontSize: 12 }}>تمپلت کاوهنگار:</span>
|
||||
<span className={`badge ${m.kavenegar_template ? 'green' : 'gray'}`} dir="ltr" style={{ fontSize: 11 }}>
|
||||
{m.kavenegar_template || 'تعریفنشده'}
|
||||
</span>
|
||||
</div>
|
||||
{m.variables.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{m.variables.map((v) => <span key={v} className="chip" dir="ltr">{`{${v}}`}</span>)}
|
||||
@@ -513,7 +520,7 @@ export default function SmsPage() {
|
||||
<button
|
||||
className="btn primary sm"
|
||||
disabled={updateMessageMut.isPending || !editBody.trim()}
|
||||
onClick={() => editMsg && updateMessageMut.mutate({ tag: editMsg.tag, body: editBody })}
|
||||
onClick={() => editMsg && updateMessageMut.mutate({ tag: editMsg.tag, body: editBody, kavenegarTemplate: editKaveName })}
|
||||
>
|
||||
{updateMessageMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
@@ -521,13 +528,22 @@ export default function SmsPage() {
|
||||
}
|
||||
>
|
||||
<div className="form-row">
|
||||
<label>متن پیامک</label>
|
||||
<label>متن پیامک (پیشنمایش/لاگ)</label>
|
||||
<textarea className="input" rows={5} dir="rtl" value={editBody} onChange={(e) => setEditBody(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>نام تمپلت کاوهنگار (VerifyLookup)</label>
|
||||
<input className="input" dir="ltr" value={editKaveName} onChange={(e) => setEditKaveName(e.target.value)} placeholder="clinicpro-otp" />
|
||||
<span className="muted" style={{ fontSize: 11, marginTop: 4 }}>
|
||||
این نام باید دقیقاً با تمپلت تأییدشده در پنل کاوهنگار یکی باشد. متن واقعی پیامک از پنل کاوهنگار ارسال میشود، نه از این متن.
|
||||
</span>
|
||||
</div>
|
||||
{editMsg && editMsg.variables.length > 0 && (
|
||||
<div style={{ marginTop: 10, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span className="muted" style={{ fontSize: 12 }}>متغیرهای مجاز:</span>
|
||||
{editMsg.variables.map((v) => <span key={v} className="chip" dir="ltr">{`{${v}}`}</span>)}
|
||||
<span className="muted" style={{ fontSize: 12 }}>متغیرها → token:</span>
|
||||
{editMsg.variables.map((v) => (
|
||||
<span key={v} className="chip" dir="ltr">{`{${v}}`}{editMsg.token_map?.[v] ? ` → %${editMsg.token_map[v]}` : ''}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -220,6 +220,8 @@ export interface SmsMessageText {
|
||||
title: string;
|
||||
body: string;
|
||||
variables: string[];
|
||||
kavenegar_template: string | null;
|
||||
token_map: Record<string, string>;
|
||||
updated_at: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ services:
|
||||
arguments:
|
||||
$otpTtl: '%env(int:OTP_TTL)%'
|
||||
$appEnv: '%kernel.environment%'
|
||||
$otpTemplate: '%env(default::KAVENEGAR_OTP_TEMPLATE)%'
|
||||
|
||||
App\Auth\Service\TokenService:
|
||||
arguments:
|
||||
|
||||
+30
-5
@@ -480,15 +480,37 @@ Updated template with `status: "rejected"`.
|
||||
|
||||
## متن ویرایشپذیر پیامکهای سیستمی
|
||||
|
||||
متن پیامکهای سیستمی (OTP، پرداخت، دعوت کلینیک، پیشثبتنام، تأیید موبایل) از پنل قابل ویرایش است و بر اساس **تگ** کلیددار میشود. هر متن placeholderهای مجاز خود را دارد (مثل `{code}`، `{doctor}`، `{date}`). هنگام ارسال، `SmsTextResolver` متنِ ویرایششدهی DB را میگیرد و placeholderها را جایگزین میکند؛ اگر رکوردی نبود به متن پیشفرض fallback میشود.
|
||||
متن پیامکهای سیستمی از پنل قابل ویرایش است و بر اساس **تگ** کلیددار میشود. هر متن placeholderهای مجاز خود را دارد (مثل `{code}`، `{doctor}`، `{date}`).
|
||||
|
||||
**همهی پیامکهای سیستمی از طریق Kavenegar VerifyLookup (`verify/lookup.json`) ارسال میشوند** (نه متنآزاد `sms/send`). هر تمپلت دو فیلد اضافه دارد:
|
||||
|
||||
- `kavenegar_template`: نام تمپلت مصوب در پنل کاوهنگار. **متن واقعیِ ارسالی از همین تمپلت پنل میآید، نه از `body` دیتابیس**؛ `body` فقط برای پیشنمایش ادمین و رندرِ رکورد `SmsLog` استفاده میشود و باید دستی با تمپلت پنل همراستا نگه داشته شود.
|
||||
- `token_map`: نگاشت متغیر منطقی → جایگاه کاوهنگار. قانون فاصله: `token`/`token2`/`token3` **فاصله نمیپذیرند**؛ `token10`/`token20` فاصله مجازند. پس مقادیر دارای فاصله (نام دکتر/بیمار/کلینیک/سایت) در `token10`/`token20`.
|
||||
|
||||
`SmsService::dispatchTemplate(tag, mobile, vars)` نقطهی واحدِ ارسال است: `kavenegar_template` و `token_map` را از رکورد DB (یا `SmsMessageTemplate::DEFAULTS`) میخواند، `vars` را به جایگاهها نگاشت میکند و با VerifyLookup میفرستد. اگر `kavenegar_template` تعریف نشده باشد → fallback به ارسال متنآزاد با `body` رندرشده.
|
||||
|
||||
نگاشت پیشفرض هر تگ (نام تمپلت پنل + token_map):
|
||||
|
||||
| تگ | kavenegar_template | token_map |
|
||||
|----|--------------------|-----------|
|
||||
| `otp` | `clinicpro-otp` | code→token, site→token10 |
|
||||
| `notification_mobile` | `clinicpro-notify-code` | code→token |
|
||||
| `payment` | `clinicpro-payment` | doctor→token10, date→token20 |
|
||||
| `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 |
|
||||
|
||||
> **پیشنیاز:** این تمپلتها باید در پنل کاوهنگار ساخته و تأیید شوند (نیازمند اشتراک advanced). تمپلتهای دارای `{link}` باید با تأیید لینک ساخته شوند.
|
||||
|
||||
> تگها: `otp`، `payment`، `clinic_invitation`، `pre_registration`، `notification_mobile`، `welcome`، `secretary`، `doctor_appointment`. (پیامک قالبیِ کاربر با تگ `user_template` جداگانه از طریق `POST /api/v1/sms/template` مدیریت میشود.)
|
||||
>
|
||||
> تگ `otp`: کد تأیید ورود. مقدار `site` از فیلد `domain` در `POST /api/v1/user/send-code` گرفته میشود: `site_name` شهرِ متناظر در جدول `cities`؛ اگر `domain` نیامد یا شهر پیدا نشد → «کلینیک پرو».
|
||||
>
|
||||
> **ارسال OTP از طریق Kavenegar VerifyLookup (پترن):** اگر متغیر محیطی `KAVENEGAR_OTP_TEMPLATE` (نام پترن مصوب پنل کاوهنگار) ست باشد، OTP با `verify/lookup.json` ارسال میشود — **متنِ پیام از پترن ثابتِ مصوب کاوهنگار میآید، نه از body قابلویرایش DB** (body صرفاً برای رکورد `SmsLog` رندر میشود). map توکنها طبق قانون فاصلهی کاوهنگار: `token` = کد ۵ رقمی (بدون فاصله)، `token10` = اسم سایت (تا ۵ فاصله مجاز). اگر پترن مصوب فقط `%token` داشته باشد، `token10` نادیده گرفته میشود (اسم سایت نمایش داده نمیشود). اگر `KAVENEGAR_OTP_TEMPLATE` خالی باشد → fallback به ارسال متنآزاد `send` با متنِ قالب DB (placeholderهای `{code}`، `{site}`). نیازمند اشتراک advanced کاوهنگار.
|
||||
> تگ `otp`: کد تأیید ورود از طریق تمپلت `clinicpro-otp` (VerifyLookup). نام تمپلت از رکورد DB خوانده میشود (متغیر محیطی `KAVENEGAR_OTP_TEMPLATE` **حذف شده**). token_map: `code→token`، `site→token10`.
|
||||
>
|
||||
> تگ `welcome`: پیامک خوشآمد که هنگام افزودن پزشک/کلینیک توسط نماینده (`POST /api/v1/representation/doctor|clinic`) بهصورت async به موبایل پزشک/مالک ارسال میشود. متن فعلاً ثابت است (نام + `site_name`)، نه از قالب DB.
|
||||
> تگ `welcome`: پیامک خوشآمد که هنگام افزودن پزشک/کلینیک توسط نماینده (`POST /api/v1/representation/doctor|clinic`) بهصورت async به موبایل پزشک/مالک ارسال میشود. از تمپلت `clinicpro-welcome` (`name→token10`, `site→token20`).
|
||||
>
|
||||
> تگ `secretary`: پیامک خوشآمد که هنگام تعریف منشی جدید (`POST /api/v1/secretary`) بهصورت async به موبایل منشی ارسال میشود. placeholderها: `{owner}` (نام دکتر یا کلینیک)، `{username}` (موبایل منشی)، `{link}` (لینک ورود). متن از قالب DB میآید (fallback به پیشفرض `SmsMessageTemplate::DEFAULTS`).
|
||||
>
|
||||
@@ -511,6 +533,8 @@ Updated template with `status: "rejected"`.
|
||||
"title": "کد تأیید ورود",
|
||||
"body": "کد تأیید شما: {code}",
|
||||
"variables": ["code", "site"],
|
||||
"kavenegar_template": "clinicpro-otp",
|
||||
"token_map": { "code": "token", "site": "token10" },
|
||||
"updated_at": 1718000000
|
||||
}
|
||||
]
|
||||
@@ -531,11 +555,12 @@ Updated template with `status: "rejected"`.
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
{ "body": "کد ورود شما: {code}" }
|
||||
{ "body": "کد ورود شما: {code}", "kavenegar_template": "clinicpro-otp" }
|
||||
```
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `body` | string | ✅ | متن جدید؛ فقط placeholderهای مجازِ همان تگ پذیرفته میشود |
|
||||
| `body` | string | ✅ | متن جدید (پیشنمایش/لاگ)؛ فقط placeholderهای مجازِ همان تگ پذیرفته میشود |
|
||||
| `kavenegar_template` | string | ❌ | نام تمپلت مصوب پنل کاوهنگار؛ رشتهی خالی → `null` |
|
||||
|
||||
#### Response `200`
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* افزودن نام تمپلت کاوهنگار (VerifyLookup) و نگاشت متغیر→token به تمپلتهای پیامک سیستمی،
|
||||
* و پرکردن مقادیر برای تگهای موجود.
|
||||
*/
|
||||
final class Version20260705072835 extends AbstractMigration
|
||||
{
|
||||
private const SEED = [
|
||||
'otp' => ['clinicpro-otp', ['code' => 'token', 'site' => 'token10']],
|
||||
'payment' => ['clinicpro-payment', ['doctor' => 'token10', 'date' => 'token20']],
|
||||
'clinic_invitation' => ['clinicpro-clinic-invite', ['clinic' => 'token10', 'link' => 'token']],
|
||||
'pre_registration' => ['clinicpro-pre-register', ['username' => 'token', 'password' => 'token2', 'link' => 'token3']],
|
||||
'notification_mobile' => ['clinicpro-notify-code', ['code' => 'token']],
|
||||
'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']],
|
||||
];
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'sms_message_templates: افزودن kavenegar_template و token_map + پرکردن تگهای موجود';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE sms_message_templates ADD kavenegar_template VARCHAR(100) DEFAULT NULL, ADD token_map JSON DEFAULT NULL');
|
||||
|
||||
foreach (self::SEED as $tag => [$template, $map]) {
|
||||
$this->addSql(
|
||||
'UPDATE sms_message_templates SET kavenegar_template = :tpl, token_map = :map WHERE tag = :tag',
|
||||
['tpl' => $template, 'map' => json_encode($map, JSON_UNESCAPED_UNICODE), 'tag' => $tag]
|
||||
);
|
||||
}
|
||||
|
||||
$this->addSql("UPDATE sms_message_templates SET token_map = '[]' WHERE token_map IS NULL");
|
||||
$this->addSql('ALTER TABLE sms_message_templates MODIFY token_map JSON NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE sms_message_templates DROP kavenegar_template, DROP token_map');
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ class NotificationMobileController extends BaseController
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly \App\Sms\Service\SmsTextResolver $smsText,
|
||||
) {}
|
||||
|
||||
// ── Request OTP ───────────────────────────────────────────────────────────
|
||||
@@ -61,13 +60,10 @@ class NotificationMobileController extends BaseController
|
||||
$this->em->flush();
|
||||
|
||||
// ارسال SMS
|
||||
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_NOTIFICATION_MOBILE, [
|
||||
'code' => $otp->getOtpCode(),
|
||||
]);
|
||||
$this->smsService->dispatchAsync(
|
||||
$this->smsService->dispatchTemplate(
|
||||
\App\Sms\Entity\SmsLog::TAG_NOTIFICATION_MOBILE,
|
||||
$mobile,
|
||||
$message,
|
||||
tag: \App\Sms\Entity\SmsLog::TAG_NOTIFICATION_MOBILE,
|
||||
['code' => $otp->getOtpCode()],
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
|
||||
@@ -33,7 +33,6 @@ class PreRegistrationController extends BaseController
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SmsService $sms,
|
||||
private readonly \App\Sms\Service\SmsTextResolver $smsText,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -155,15 +154,14 @@ class PreRegistrationController extends BaseController
|
||||
$this->em->flush();
|
||||
|
||||
try {
|
||||
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION, [
|
||||
'username' => $preReg->getMobile(),
|
||||
'password' => $password,
|
||||
'link' => 'https://clinic-pro.ddev.site/admin',
|
||||
]);
|
||||
$this->sms->dispatchAsync(
|
||||
$this->sms->dispatchTemplate(
|
||||
\App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION,
|
||||
$preReg->getMobile(),
|
||||
$message,
|
||||
tag: \App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION,
|
||||
[
|
||||
'username' => $preReg->getMobile(),
|
||||
'password' => $password,
|
||||
'link' => 'https://clinic-pro.ddev.site/admin',
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('PreRegistration SMS failed', ['uuid' => $uuid, 'error' => $e->getMessage()]);
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Service\SmsService;
|
||||
use App\Sms\Service\SmsTextResolver;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
@@ -16,11 +15,9 @@ class OtpService
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly SmsService $sms,
|
||||
private readonly SmsTextResolver $smsText,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly int $otpTtl = 1200,
|
||||
private readonly string $appEnv = 'dev',
|
||||
private readonly ?string $otpTemplate = null,
|
||||
) {}
|
||||
|
||||
private function key(string $uuid): string
|
||||
@@ -72,21 +69,8 @@ class OtpService
|
||||
$this->cache->save($item);
|
||||
|
||||
if ($this->appEnv !== 'dev') {
|
||||
$site = $this->resolveSiteName($domain);
|
||||
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]);
|
||||
|
||||
if ($this->otpTemplate) {
|
||||
// Kavenegar VerifyLookup: code has no spaces → token; site may contain spaces → token10.
|
||||
$this->sms->dispatchAsync(
|
||||
$mobile,
|
||||
$message,
|
||||
templateCode: $this->otpTemplate,
|
||||
templateVars: ['token' => $code, 'token10' => $site],
|
||||
tag: SmsLog::TAG_OTP,
|
||||
);
|
||||
} else {
|
||||
$this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP);
|
||||
}
|
||||
$site = $this->resolveSiteName($domain);
|
||||
$this->sms->dispatchTemplate(SmsLog::TAG_OTP, $mobile, ['code' => $code, 'site' => $site]);
|
||||
}
|
||||
|
||||
return $uuid;
|
||||
|
||||
@@ -17,7 +17,6 @@ class ClinicInvitationService
|
||||
private readonly ClinicDoctorInvitationRepository $repo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly \App\Sms\Service\SmsTextResolver $smsText,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
@@ -113,11 +112,9 @@ class ClinicInvitationService
|
||||
$clinicName = $clinic->getName() ?? 'کلینیک';
|
||||
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
|
||||
|
||||
$message = $this->smsText->resolve(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, [
|
||||
$this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, $inv->getMobile(), [
|
||||
'clinic' => $clinicName,
|
||||
'link' => $link,
|
||||
]);
|
||||
|
||||
$this->smsService->dispatchAsync($inv->getMobile(), $message, tag: \App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ use App\Representation\Service\JalaliDateService;
|
||||
use App\Settlement\Service\CommissionService;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Service\SmsService;
|
||||
use App\Sms\Service\SmsTextResolver;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -38,7 +37,6 @@ final class PaymentManager
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SmsWalletService $smsWalletService,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly SmsTextResolver $smsText,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly CommissionService $commissionService,
|
||||
@@ -314,23 +312,21 @@ final class PaymentManager
|
||||
|
||||
$mobile = $appointment->getPatientMobile();
|
||||
if ($mobile) {
|
||||
$when = $this->jalali->formatDateTime($appointment->getSlotStart());
|
||||
$message = $this->smsText->resolve(SmsLog::TAG_PAYMENT, [
|
||||
$when = $this->jalali->formatDateTime($appointment->getSlotStart());
|
||||
$this->smsService->dispatchTemplate(SmsLog::TAG_PAYMENT, $mobile, [
|
||||
'doctor' => $doctor->getName(),
|
||||
'date' => $when,
|
||||
]);
|
||||
$this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_PAYMENT);
|
||||
}
|
||||
|
||||
// اعلان به شمارهٔ «اعلان نوبت» دکتر — فقط برای نوبتهای پرداختشدهٔ سایت (همین مسیر).
|
||||
$notify = $doctor->getNotificationMobile();
|
||||
if ($notify) {
|
||||
$docMessage = $this->smsText->resolve(SmsLog::TAG_DOCTOR_APPOINTMENT, [
|
||||
$this->smsService->dispatchTemplate(SmsLog::TAG_DOCTOR_APPOINTMENT, $notify, [
|
||||
'patient' => $appointment->getPatientName() ?? '—',
|
||||
'date' => $this->jalali->formatDateTime($appointment->getSlotStart(), false),
|
||||
'time' => date('H:i', $appointment->getSlotStart()),
|
||||
]);
|
||||
$this->smsService->dispatchAsync($notify, $docMessage, tag: SmsLog::TAG_DOCTOR_APPOINTMENT);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,10 +310,10 @@ class RepresentationActionController extends BaseController
|
||||
|
||||
// پیام خوشآمد به پزشک
|
||||
$siteName = $this->configRepo->get('site_name') ?: 'کلینیکپرو';
|
||||
$this->smsService->dispatchAsync(
|
||||
$this->smsService->dispatchTemplate(
|
||||
\App\Sms\Entity\SmsLog::TAG_WELCOME,
|
||||
$mobile,
|
||||
sprintf('دکتر %s عزیز، به %s خوش آمدید. پروفایل شما توسط نماینده ثبت شد.', $name, $siteName),
|
||||
tag: \App\Sms\Entity\SmsLog::TAG_WELCOME,
|
||||
['name' => $name, 'site' => $siteName],
|
||||
);
|
||||
|
||||
return $this->success(['uuid' => $doctor->getUuid()], 201);
|
||||
@@ -385,10 +385,10 @@ class RepresentationActionController extends BaseController
|
||||
|
||||
// پیام خوشآمد به مالک کلینیک
|
||||
$siteName = $this->configRepo->get('site_name') ?: 'کلینیکپرو';
|
||||
$this->smsService->dispatchAsync(
|
||||
$this->smsService->dispatchTemplate(
|
||||
\App\Sms\Entity\SmsLog::TAG_WELCOME,
|
||||
$mobile,
|
||||
sprintf('کلینیک %s به %s خوش آمد. پروفایل کلینیک شما توسط نماینده ثبت شد.', $name, $siteName),
|
||||
tag: \App\Sms\Entity\SmsLog::TAG_WELCOME,
|
||||
['name' => $name, 'site' => $siteName],
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
|
||||
@@ -12,7 +12,6 @@ use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Service\SmsService;
|
||||
use App\Sms\Service\SmsTextResolver;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -34,7 +33,6 @@ class SecretaryController extends BaseController
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly SmsTextResolver $smsText,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
@@ -130,13 +128,11 @@ class SecretaryController extends BaseController
|
||||
|
||||
$link = rtrim($this->appUrl, '/') . '/login';
|
||||
|
||||
$message = $this->smsText->resolve(SmsLog::TAG_SECRETARY, [
|
||||
$this->smsService->dispatchTemplate(SmsLog::TAG_SECRETARY, $mobile, [
|
||||
'owner' => $ownerName,
|
||||
'username' => $mobile,
|
||||
'link' => $link,
|
||||
]);
|
||||
|
||||
$this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_SECRETARY);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/secretary/{uuid}', methods: ['GET'])]
|
||||
|
||||
@@ -23,19 +23,33 @@ class SeedSmsMessageTemplatesCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$created = 0;
|
||||
$backfilled = 0;
|
||||
foreach (SmsMessageTemplate::DEFAULTS as $tag => $def) {
|
||||
if ($this->repo->findByTag($tag) !== null) {
|
||||
$existing = $this->repo->findByTag($tag);
|
||||
if ($existing !== null) {
|
||||
if ($existing->getKavenegarTemplate() === null) {
|
||||
$existing->setKavenegarTemplate($def['kavenegar_template']);
|
||||
$existing->setTokenMap($def['token_map']);
|
||||
$backfilled++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$this->repo->save(
|
||||
new SmsMessageTemplate($tag, $def['title'], $def['body'], $def['variables']),
|
||||
new SmsMessageTemplate(
|
||||
$tag,
|
||||
$def['title'],
|
||||
$def['body'],
|
||||
$def['variables'],
|
||||
$def['kavenegar_template'],
|
||||
$def['token_map'],
|
||||
),
|
||||
false,
|
||||
);
|
||||
$created++;
|
||||
}
|
||||
$this->repo->getEntityManager()->flush();
|
||||
|
||||
$output->writeln(sprintf('Seeded %d sms message templates.', $created));
|
||||
$output->writeln(sprintf('Seeded %d, backfilled %d sms message templates.', $created, $backfilled));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,13 @@ class SmsMessageController extends BaseController
|
||||
$items = [];
|
||||
foreach (SmsMessageTemplate::DEFAULTS as $tag => $def) {
|
||||
$items[] = $existing[$tag] ?? [
|
||||
'tag' => $tag,
|
||||
'title' => $def['title'],
|
||||
'body' => $def['body'],
|
||||
'variables' => $def['variables'],
|
||||
'updated_at' => null,
|
||||
'tag' => $tag,
|
||||
'title' => $def['title'],
|
||||
'body' => $def['body'],
|
||||
'variables' => $def['variables'],
|
||||
'kavenegar_template' => $def['kavenegar_template'],
|
||||
'token_map' => $def['token_map'],
|
||||
'updated_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -95,13 +97,25 @@ class SmsMessageController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
$def = SmsMessageTemplate::DEFAULTS[$tag];
|
||||
$tpl = $this->repo->findByTag($tag);
|
||||
if ($tpl === null) {
|
||||
$def = SmsMessageTemplate::DEFAULTS[$tag];
|
||||
$tpl = new SmsMessageTemplate($tag, $def['title'], $body, $def['variables']);
|
||||
$tpl = new SmsMessageTemplate(
|
||||
$tag,
|
||||
$def['title'],
|
||||
$body,
|
||||
$def['variables'],
|
||||
$def['kavenegar_template'],
|
||||
$def['token_map'],
|
||||
);
|
||||
} else {
|
||||
$tpl->setBody($body);
|
||||
}
|
||||
|
||||
if (array_key_exists('kavenegar_template', $data)) {
|
||||
$name = trim((string) $data['kavenegar_template']);
|
||||
$tpl->setKavenegarTemplate($name !== '' ? $name : null);
|
||||
}
|
||||
$this->repo->save($tpl);
|
||||
|
||||
return $this->success(['data' => $tpl->toArray()]);
|
||||
|
||||
@@ -15,43 +15,66 @@ class SmsMessageTemplate
|
||||
{
|
||||
/**
|
||||
* متن و متادیتای پیشفرض هر تگ — مرجع برای seed و fallback.
|
||||
* @var array<string, array{title: string, body: string, variables: string[]}>
|
||||
* kavenegar_template: نام تمپلت ساختهشده در پنل کاوهنگار (VerifyLookup).
|
||||
* token_map: نگاشت متغیر منطقی → جایگاه کاوهنگار (token/token2/token3 بدون فاصله، token10/token20 با فاصله).
|
||||
* @var array<string, array{title: string, body: string, variables: string[], kavenegar_template: string, token_map: array<string,string>}>
|
||||
*/
|
||||
public const DEFAULTS = [
|
||||
SmsLog::TAG_OTP => [
|
||||
'title' => 'کد تأیید ورود',
|
||||
'body' => 'کد تأیید شما: {code}',
|
||||
'variables' => ['code', 'site'],
|
||||
'kavenegar_template' => 'clinicpro-otp',
|
||||
'token_map' => ['code' => 'token', 'site' => 'token10'],
|
||||
],
|
||||
SmsLog::TAG_PAYMENT => [
|
||||
'title' => 'تأیید پرداخت و نوبت',
|
||||
'body' => 'نوبت شما با {doctor} در تاریخ {date} ثبت و تأیید شد.',
|
||||
'variables' => ['doctor', 'date'],
|
||||
'kavenegar_template' => 'clinicpro-payment',
|
||||
'token_map' => ['doctor' => 'token10', 'date' => 'token20'],
|
||||
],
|
||||
SmsLog::TAG_CLINIC_INVITATION => [
|
||||
'title' => 'دعوت پزشک به کلینیک',
|
||||
'body' => "دکتر گرامی، کلینیک {clinic} شما را برای همکاری دعوت کرده است.\nبرای بررسی: {link}\nاین لینک تا ۷۲ ساعت معتبر است.",
|
||||
'variables' => ['clinic', 'link'],
|
||||
'kavenegar_template' => 'clinicpro-clinic-invite',
|
||||
'token_map' => ['clinic' => 'token10', 'link' => 'token'],
|
||||
],
|
||||
SmsLog::TAG_PRE_REGISTRATION => [
|
||||
'title' => 'پیشثبتنام',
|
||||
'body' => 'به کلینیک پرو خوش آمدید! شمارهکاربری: {username} | رمز عبور: {password} | لینک ورود: {link}',
|
||||
'variables' => ['username', 'password', 'link'],
|
||||
'kavenegar_template' => 'clinicpro-pre-register',
|
||||
'token_map' => ['username' => 'token', 'password' => 'token2', 'link' => 'token3'],
|
||||
],
|
||||
SmsLog::TAG_NOTIFICATION_MOBILE => [
|
||||
'title' => 'تأیید شماره اعلان',
|
||||
'body' => "کد تأیید شماره اعلان شما: {code}\nاعتبار: ۵ دقیقه",
|
||||
'variables' => ['code'],
|
||||
'kavenegar_template' => 'clinicpro-notify-code',
|
||||
'token_map' => ['code' => 'token'],
|
||||
],
|
||||
SmsLog::TAG_SECRETARY => [
|
||||
'title' => 'دعوت بهعنوان منشی',
|
||||
'body' => "شما بهعنوان منشیِ {owner} در کلینیکپرو تعریف شدید.\nشمارهکاربری: {username}\nلینک ورود: {link}",
|
||||
'variables' => ['owner', 'username', 'link'],
|
||||
'kavenegar_template' => 'clinicpro-secretary',
|
||||
'token_map' => ['owner' => 'token10', 'username' => 'token', 'link' => 'token3'],
|
||||
],
|
||||
SmsLog::TAG_DOCTOR_APPOINTMENT => [
|
||||
'title' => 'نوبت جدید (اعلان به پزشک)',
|
||||
'body' => "نوبت جدید ثبت شد.\nبیمار: {patient}\nتاریخ: {date} ساعت {time}\n\nبرای مدیریت نوبتهای خود بهصورت رایگان به سایت clinic-pro.ir مراجعه کنید.\nکلینیک پرو",
|
||||
'variables' => ['patient', 'date', 'time'],
|
||||
'kavenegar_template' => 'clinicpro-doctor-appt',
|
||||
'token_map' => ['patient' => 'token10', 'date' => 'token2', 'time' => 'token3'],
|
||||
],
|
||||
SmsLog::TAG_WELCOME => [
|
||||
'title' => 'خوشآمدگویی',
|
||||
'body' => '{name} عزیز، به {site} خوش آمدید. پروفایل شما توسط نماینده ثبت شد.',
|
||||
'variables' => ['name', 'site'],
|
||||
'kavenegar_template' => 'clinicpro-welcome',
|
||||
'token_map' => ['name' => 'token10', 'site' => 'token20'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -72,36 +95,56 @@ class SmsMessageTemplate
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $variables = [];
|
||||
|
||||
#[ORM\Column(name: 'kavenegar_template', type: 'string', length: 100, nullable: true)]
|
||||
private ?string $kavenegarTemplate = null;
|
||||
|
||||
#[ORM\Column(name: 'token_map', type: 'json')]
|
||||
private array $tokenMap = [];
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $tag, string $title, string $body, array $variables = [])
|
||||
{
|
||||
$this->tag = $tag;
|
||||
$this->title = $title;
|
||||
$this->body = $body;
|
||||
$this->variables = $variables;
|
||||
$this->updatedAt = time();
|
||||
public function __construct(
|
||||
string $tag,
|
||||
string $title,
|
||||
string $body,
|
||||
array $variables = [],
|
||||
?string $kavenegarTemplate = null,
|
||||
array $tokenMap = [],
|
||||
) {
|
||||
$this->tag = $tag;
|
||||
$this->title = $title;
|
||||
$this->body = $body;
|
||||
$this->variables = $variables;
|
||||
$this->kavenegarTemplate = $kavenegarTemplate;
|
||||
$this->tokenMap = $tokenMap;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getTag(): string { return $this->tag; }
|
||||
public function getTitle(): string { return $this->title; }
|
||||
public function getBody(): string { return $this->body; }
|
||||
public function getVariables(): array { return $this->variables; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getTag(): string { return $this->tag; }
|
||||
public function getTitle(): string { return $this->title; }
|
||||
public function getBody(): string { return $this->body; }
|
||||
public function getVariables(): array { return $this->variables; }
|
||||
public function getKavenegarTemplate(): ?string { return $this->kavenegarTemplate; }
|
||||
public function getTokenMap(): array { return $this->tokenMap; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setTitle(string $v): self { $this->title = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setBody(string $v): self { $this->body = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setKavenegarTemplate(?string $v): self { $this->kavenegarTemplate = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setTokenMap(array $v): self { $this->tokenMap = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'tag' => $this->tag,
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'variables' => $this->variables,
|
||||
'updated_at' => $this->updatedAt,
|
||||
'tag' => $this->tag,
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'variables' => $this->variables,
|
||||
'kavenegar_template' => $this->kavenegarTemplate,
|
||||
'token_map' => $this->tokenMap,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace App\Sms\Service;
|
||||
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Entity\SmsMessageTemplate;
|
||||
use App\Sms\Message\SendSmsMessage;
|
||||
use App\Sms\Provider\KavehNegarProvider;
|
||||
use App\Sms\Provider\RanginehProvider;
|
||||
use App\Sms\Provider\SmsProviderInterface;
|
||||
use App\Sms\Repository\SmsLogRepository;
|
||||
use App\Sms\Repository\SmsMessageTemplateRepository;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
class SmsService
|
||||
@@ -15,10 +17,12 @@ class SmsService
|
||||
private array $providers;
|
||||
|
||||
public function __construct(
|
||||
private readonly KavehNegarProvider $kavenegar,
|
||||
private readonly RanginehProvider $rangineh,
|
||||
private readonly SmsLogRepository $logRepo,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly KavehNegarProvider $kavenegar,
|
||||
private readonly RanginehProvider $rangineh,
|
||||
private readonly SmsLogRepository $logRepo,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly SmsMessageTemplateRepository $messageTemplateRepo,
|
||||
private readonly SmsTextResolver $textResolver,
|
||||
) {
|
||||
$this->providers = [
|
||||
'kavenegar' => $kavenegar,
|
||||
@@ -26,6 +30,37 @@ class SmsService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ارسال یک پیامک سیستمی از طریق تمپلت VerifyLookup کاوهنگار.
|
||||
* فقط تگ + متغیرهای منطقی میگیرد؛ نام تمپلت و نگاشت token از SmsMessageTemplate (یا DEFAULTS) خوانده میشود.
|
||||
*
|
||||
* @param array<string,string|int> $vars متغیرهای منطقی (کلیدهای {...} بدنه)
|
||||
*/
|
||||
public function dispatchTemplate(string $tag, string $mobile, array $vars = [], string $provider = 'kavenegar'): void
|
||||
{
|
||||
$tpl = $this->messageTemplateRepo->findByTag($tag);
|
||||
$kaveTemplate = $tpl?->getKavenegarTemplate()
|
||||
?? (SmsMessageTemplate::DEFAULTS[$tag]['kavenegar_template'] ?? null);
|
||||
$tokenMap = $tpl?->getTokenMap()
|
||||
?: (SmsMessageTemplate::DEFAULTS[$tag]['token_map'] ?? []);
|
||||
|
||||
$message = $this->textResolver->resolve($tag, $vars);
|
||||
|
||||
if ($kaveTemplate === null || $tokenMap === []) {
|
||||
$this->dispatchAsync($mobile, $message, $provider, tag: $tag);
|
||||
return;
|
||||
}
|
||||
|
||||
$slotVars = [];
|
||||
foreach ($tokenMap as $logicalKey => $slot) {
|
||||
if (array_key_exists($logicalKey, $vars)) {
|
||||
$slotVars[$slot] = (string) $vars[$logicalKey];
|
||||
}
|
||||
}
|
||||
|
||||
$this->dispatchAsync($mobile, $message, $provider, templateVars: $slotVars, templateCode: $kaveTemplate, tag: $tag);
|
||||
}
|
||||
|
||||
public function dispatchAsync(
|
||||
string $mobile,
|
||||
string $message,
|
||||
|
||||
Reference in New Issue
Block a user