feat: Add tagging system for SMS logs and templates
- Introduced a `tag` field in the `SmsLog` entity to categorize SMS messages. - Updated the `SmsService` to handle the new `tag` parameter during SMS dispatch. - Implemented a `SmsTextResolver` service to resolve SMS message templates based on tags. - Created a new `SmsMessageTemplate` entity for editable SMS templates with placeholders. - Added endpoints for managing SMS message templates in the admin panel. - Enhanced existing SMS dispatching methods across various controllers to utilize the tagging system. - Migrated the database to include the new `tag` field and created a seeding command for default SMS templates. - Updated admin API to filter SMS logs by tag and include tag information in responses.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
# تگگذاری و لیست کامل پیامکهای ارسالی
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend SMS + Admin React SPA). کاملاً داخل همین پروژه است.
|
||||
|
||||
## زمینه
|
||||
|
||||
هر پیامکی که سیستم ارسال میکند در جدول `sms_logs` (Entity `SmsLog`) ذخیره و در تب «لاگها»ی صفحهی `/admin/sms` (`SmsPage`) از طریق `GET /api/v1/admin/sms/logs` نمایش داده میشود. اما:
|
||||
|
||||
1. لاگها **تگ/نوع** ندارند — نمیتوان فهمید یک پیامک مربوط به کدام بخش است (OTP ورود، تأیید پرداخت، دعوت کلینیک، پیشثبتنام، تأیید موبایل اعلان، یا پیامکِ قالبیِ کاربرِ پنل).
|
||||
2. مسیر ارسال یکدست نیست: بیشتر جاها از `SmsService::dispatchAsync(...)` استفاده میکنند، ولی `OtpService` مستقیماً `bus->dispatch(new SendSmsMessage(...))` صدا میزند.
|
||||
|
||||
هدف: همهی پیامکهای ارسالی با یک **تگ** ذخیره شوند (مثل `global` برای پیامکهای سیستمیِ خودِ اپلیکیشن: OTP، تأیید پرداخت، و...) و در صفحهی `/admin/sms` با ستون/فیلتر تگ دیده شوند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
افزودن فیلد `tag` به جریان ارسال و لاگ پیامک، تگگذاری همهی نقاط ارسال، نمایش/فیلتر تگ در پنل ادمین، و **ویرایشپذیر کردن متن همهی پیامکها از پنل** (متنهای سیستمی که الان هاردکدند، بر اساس تگ قابل ویرایش شوند).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Sms/Entity/SmsLog.php` | افزودن ستون `tag` (نیازمند migration) |
|
||||
| `src/Sms/Message/SendSmsMessage.php` | DTO صف — افزودن `tag` |
|
||||
| `src/Sms/Service/SmsService.php` | `dispatchAsync` و `sendNow` — عبور و ثبت `tag` |
|
||||
| `src/Auth/Service/OtpService.php` | ارسال مستقیم OTP (باید از مسیر تگدار رد شود) |
|
||||
| `src/Payment/Controller/PaymentController.php` | پیامک تأیید پرداخت |
|
||||
| `src/ClinicInvitation/Service/ClinicInvitationService.php` | پیامک دعوت پزشک به کلینیک |
|
||||
| `src/Auth/Controller/PreRegistrationController.php` | پیامک پیشثبتنام |
|
||||
| `src/Auth/Controller/NotificationMobileController.php` | پیامک تأیید موبایلِ اعلان |
|
||||
| `src/Admin/Controller/AdminApiController.php` | `smsLogs()` — افزودن `tag` به خروجی + فیلتر query |
|
||||
| `src/Sms/Entity/SmsMessageTemplate.php` (جدید) | متن ویرایشپذیرِ سیستمی بر اساس تگ (یا فیلد جدید روی `SmsTemplate`) |
|
||||
| `src/Sms/Service/SmsTextResolver.php` (جدید) | resolve متن بر اساس تگ + جایگزینی placeholder + fallback به متن هاردکد |
|
||||
| `src/Sms/Controller/SmsMessageController.php` (جدید) | `GET/PATCH /api/v1/admin/sms/messages` |
|
||||
| `src/Sms/Entity/SmsTemplate.php` | قالب کاربر — از قبل با `updateTemplate`/`setBody` ویرایشپذیر است (مرجع) |
|
||||
| `assets/admin/pages/SmsPage.tsx` | تب «لاگها» (ستون/فیلتر تگ) + تب جدید «متن پیامکها» (ویرایش) |
|
||||
| `assets/admin/types/index.ts` | type `SmsLog` (+ `tag`) و type متن سیستمی |
|
||||
| `docs/api/sms.md`, `docs/api/admin.md` | مستندسازی |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
`SmsLog` بدون tag:
|
||||
```php
|
||||
#[ORM\Column(type: 'text')] private string $message;
|
||||
#[ORM\Column(type: 'string', length: 20)] private string $provider;
|
||||
#[ORM\Column(type: 'boolean')] private bool $success;
|
||||
#[ORM\Column(name: 'template_uuid', type: 'string', length: 36, nullable: true)] private ?string $templateUuid = null;
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt;
|
||||
// constructor: __construct(string $mobile, string $message, string $provider, bool $success)
|
||||
```
|
||||
|
||||
`SendSmsMessage` DTO:
|
||||
```php
|
||||
public function __construct(
|
||||
public readonly string $mobile,
|
||||
public readonly string $message,
|
||||
public readonly string $provider = 'kavenegar',
|
||||
public readonly ?string $templateUuid = null,
|
||||
public readonly array $templateVars = [],
|
||||
public readonly ?string $templateCode = null,
|
||||
) {}
|
||||
```
|
||||
|
||||
`SmsService` (ثبت لاگ بدون tag):
|
||||
```php
|
||||
public function dispatchAsync(string $mobile, string $message, string $provider = 'kavenegar',
|
||||
?string $templateUuid = null, array $templateVars = [], ?string $templateCode = null): void
|
||||
{
|
||||
$this->bus->dispatch(new SendSmsMessage($mobile, $message, $provider, $templateUuid, $templateVars, $templateCode));
|
||||
}
|
||||
|
||||
public function sendNow(SendSmsMessage $msg): bool
|
||||
{
|
||||
$provider = $this->resolveProvider($msg->provider);
|
||||
$success = ($msg->templateCode !== null)
|
||||
? $provider->sendTemplate($msg->mobile, $msg->templateCode, $msg->templateVars)
|
||||
: $provider->send($msg->mobile, $msg->message);
|
||||
$log = new SmsLog($msg->mobile, $msg->message, $provider->getName(), $success);
|
||||
if ($msg->templateUuid) $log->setTemplateUuid($msg->templateUuid);
|
||||
$this->logRepo->save($log);
|
||||
return $success;
|
||||
}
|
||||
```
|
||||
|
||||
`OtpService` — مسیر مستقیم (بدون tag، بدون dispatchAsync):
|
||||
```php
|
||||
$this->bus->dispatch(new SendSmsMessage($mobile, "کد تأیید شما: {$code}"));
|
||||
```
|
||||
|
||||
admin `smsLogs()` خروجی فعلی:
|
||||
```php
|
||||
->select('s.uuid, s.mobile, s.message, s.provider, s.success, s.createdAt')
|
||||
// items: uuid, recipient(mobile), message, status(sent/failed), provider, sent_at
|
||||
```
|
||||
|
||||
`SmsPage.tsx` تبها: `'samples' | 'pending' | 'logs' | 'post-visit-review'`؛ تب logs از `/api/v1/admin/sms/logs` میخواند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. افزودن `tag` به `SmsLog` + migration
|
||||
|
||||
ستون `tag` (string، طول ۳۰، nullable=false، پیشفرض `'global'`) به `SmsLog` اضافه کن؛ getter/setter و پارامتر constructor (با مقدار پیشفرض `'global'`):
|
||||
```php
|
||||
#[ORM\Column(type: 'string', length: 30, options: ['default' => 'global'])]
|
||||
private string $tag = 'global';
|
||||
|
||||
public function __construct(string $mobile, string $message, string $provider, bool $success, string $tag = 'global')
|
||||
{ /* ...; $this->tag = $tag; */ }
|
||||
|
||||
public function getTag(): string { return $this->tag; }
|
||||
public function setTag(string $v): self { $this->tag = $v; return $this; }
|
||||
```
|
||||
سپس migration:
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
> ثابتهای تگ را در یک جای واحد تعریف کن (مثلاً `SmsLog::TAG_GLOBAL = 'global'` و سایر تگها) تا رشتهی جادویی پخش نشود.
|
||||
|
||||
### ۲. عبور `tag` در DTO و SmsService
|
||||
|
||||
- در `SendSmsMessage` پارامتر `public readonly string $tag = 'global'` را اضافه کن (انتهای لیست، با پیشفرض).
|
||||
- در `SmsService::dispatchAsync` پارامتر `string $tag = 'global'` اضافه و به `SendSmsMessage` پاس بده.
|
||||
- در `SmsService::sendNow` هنگام ساخت `SmsLog`، `$msg->tag` را پاس بده:
|
||||
```php
|
||||
$log = new SmsLog($msg->mobile, $msg->message, $provider->getName(), $success, $msg->tag);
|
||||
```
|
||||
|
||||
### ۳. تگگذاری همهی نقاط ارسال
|
||||
|
||||
برای هر caller یک تگ معنادار بده (بهجای پیشفرض). تگهای پیشنهادی:
|
||||
|
||||
| caller | تگ |
|
||||
|--------|----|
|
||||
| `OtpService` (کد تأیید ورود) | `otp` |
|
||||
| `PaymentController` (تأیید پرداخت) | `payment` |
|
||||
| `ClinicInvitationService` (دعوت پزشک) | `clinic_invitation` |
|
||||
| `PreRegistrationController` (پیشثبتنام) | `pre_registration` |
|
||||
| `NotificationMobileController` (تأیید موبایل اعلان) | `notification_mobile` |
|
||||
| ارسالهای قالبیِ کاربر پنل از `SmsController::send`/`sendViaTemplate` (پیامکهای خود کاربر، نه سیستمی) | `user_template` |
|
||||
| هر ارسال سیستمی دیگر بدون تگ مشخص | `global` (پیشفرض) |
|
||||
|
||||
- **مهم — `OtpService`:** الان مستقیم `bus->dispatch(new SendSmsMessage(...))` میزند. یا `SendSmsMessage(..., tag: 'otp')` بده، یا بهتر آن را به `SmsService::dispatchAsync($mobile, $message, tag: 'otp')` تبدیل کن تا مسیر یکدست شود.
|
||||
- در `SmsController` (ارسالهای کاربرِ پنل) هنگام لاگ، تگ `user_template` ست شود.
|
||||
|
||||
### ۴. admin endpoint — افزودن tag به خروجی + فیلتر
|
||||
|
||||
در `AdminApiController::smsLogs()`:
|
||||
- `s.tag` را به `select` و به آیتم خروجی اضافه کن (`'tag' => $l['tag']`).
|
||||
- پارامتر query اختیاری `tag` برای فیلتر:
|
||||
```php
|
||||
$tag = trim((string) $request->query->get('tag', ''));
|
||||
if ($tag !== '') { $qb->andWhere('s.tag = :tag')->setParameter('tag', $tag); }
|
||||
```
|
||||
- ساختار `paginated()` حفظ شود.
|
||||
|
||||
### ۵. Admin SPA — ستون تگ + فیلتر در تب لاگها
|
||||
|
||||
در `SmsPage.tsx` (تب `logs`):
|
||||
- ستون «تگ» به جدول لاگها اضافه کن (با برچسب فارسیِ خوانا — یک map از tag→label فارسی: `global`→«سیستمی»، `otp`→«کد تأیید»، `payment`→«پرداخت»، `clinic_invitation`→«دعوت کلینیک»، `pre_registration`→«پیشثبتنام»، `notification_mobile`→«تأیید موبایل»، `user_template`→«قالب کاربر»).
|
||||
- یک فیلتر کشویی تگ بالای جدول که `?tag=` را به query اضافه میکند.
|
||||
- در `types/index.ts`، `SmsLog` فیلد `tag: string` بگیرد.
|
||||
|
||||
### ۶. ویرایشپذیر کردن متن همهی پیامکها
|
||||
|
||||
الان متن پیامکهای **سیستمی** در کد هاردکد است و از پنل قابل ویرایش نیست — مثلاً:
|
||||
```php
|
||||
// OtpService.php
|
||||
$this->bus->dispatch(new SendSmsMessage($mobile, "کد تأیید شما: {$code}"));
|
||||
// PaymentController.php و ClinicInvitationService.php هم متن inline دارند
|
||||
```
|
||||
در مقابل، قالبهای کاربر (`SmsTemplate`) از قبل با `PATCH /api/v1/sms/template/{uuid}` (`setBody`) ویرایشپذیرند. هدف: متن **هر پیامک سیستمی (بر اساس تگ)** هم از پنل قابل ویرایش شود.
|
||||
|
||||
راهحل — یک منبعِ متنِ ویرایشپذیر کلیددار با تگ:
|
||||
|
||||
- یک Entity سبک `SmsMessageTemplate` (یا استفاده از همان `SmsTemplate` با یک فیلد `tag` یکتا) که برای هر تگِ سیستمی یک رکورد دارد: `tag` (یکتا)، `title` (فارسی)، `body` (متن با placeholderها مثل `{code}`، `{amount}`)، `variables` (لیست placeholderهای مجاز)، `updated_at`. migration لازم است.
|
||||
- یک سرویس `SmsTextResolver::resolve(string $tag, array $vars): string` که body ویرایششدهی همان تگ را از DB میگیرد، placeholderها را جایگزین میکند، و اگر رکوردی نبود به متنِ هاردکدِ پیشفرض fallback میکند (هیچ پیامکی بدون متن نماند).
|
||||
- نقاط ارسال سیستمی (OtpService، PaymentController، ClinicInvitationService، PreRegistrationController، NotificationMobileController) بهجای رشتهی inline، متن را از `SmsTextResolver::resolve('<tag>', [...vars])` بگیرند.
|
||||
- **Seed/تأمین رکوردهای پیشفرض:** یک data fixture یا command که برای هر تگ سیستمی رکورد اولیه با متن فعلی بسازد (تا پنل از روز اول مقدار داشته باشد).
|
||||
|
||||
endpointهای ادمین برای ویرایش متنهای سیستمی:
|
||||
```
|
||||
GET /api/v1/admin/sms/messages # لیست متنهای سیستمی (tag/title/body/variables)
|
||||
PATCH /api/v1/admin/sms/messages/{tag} # ویرایش body یک تگ
|
||||
```
|
||||
- هر دو `#[IsGranted('ROLE_ADMIN')]`؛ پاسخها با `success()`/`paginated()`.
|
||||
- در ویرایش، اعتبارسنجی کن که فقط placeholderهای مجازِ همان تگ در body استفاده شده باشند (placeholder ناشناخته → خطای ۴۲۲).
|
||||
|
||||
Admin SPA — تب جدید «متن پیامکها» در `SmsPage.tsx`:
|
||||
- لیست متنهای سیستمی بر اساس تگ (با `title` فارسی).
|
||||
- فرم ویرایش `body` (React Hook Form + Zod) با نمایش placeholderهای مجاز هر تگ بهصورت راهنما.
|
||||
- ذخیره با `PATCH /api/v1/admin/sms/messages/{tag}` و invalidate کوئری.
|
||||
|
||||
> پیامکهای قالبیِ کاربر (`SmsTemplate`، تگ `user_template`) از قبل ویرایشپذیرند — این بخش فقط برای متنهای **سیستمی** (تگهای `otp`/`payment`/`clinic_invitation`/`pre_registration`/`notification_mobile`/`global`) است.
|
||||
|
||||
### ۷. مستندسازی
|
||||
|
||||
- `docs/api/sms.md`: فیلد `tag` در لاگ پیامک و مقادیر مجاز؛ و endpointهای جدید متنهای سیستمی (`GET/PATCH /api/v1/admin/sms/messages`) با placeholderهای هر تگ.
|
||||
- `docs/api/admin.md`: در `GET /api/v1/admin/sms/logs`، فیلد `tag` در پاسخ و پارامتر query `tag`.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- migration الزامی است (ستون جدید روی `sms_logs`). برای ردیفهای موجود `default 'global'` اعمال شود تا NULL نشوند.
|
||||
- پیشفرض همهجا `global` باشد تا اگر نقطهای تگگذاری نشد، پیامک باز هم لاگ و دستهبندی شود (هیچ پیامکی بیتگ نماند).
|
||||
- مسیر صف (Messenger): چون `SendSmsMessage` فیلد جدید میگیرد، مطمئن شو پیامهای در صف قدیمی مشکل deserialization ندارند (پیشفرض پارامتر این را پوشش میدهد).
|
||||
- تاریخها Unix timestamp؛ خروجی admin با `paginated()`؛ در SPA: items از `data?.data`، total از `data?.meta?.totalRecords`.
|
||||
- تگها را بهصورت ثابت (const) در بکاند نگهدار و در فرانت map فارسی جدا داشته باش؛ رشتهی جادویی تکرار نشود.
|
||||
- **متن ویرایشپذیر (وظیفه ۶):** `SmsTextResolver` باید همیشه fallback به متن هاردکد داشته باشد تا اگر ادمین متنی ثبت نکرده یا رکورد تگ نبود، پیامک با متن پیشفرض ارسال شود (هیچ پیامکی با متن خالی نرود). placeholderهای هر تگ ثابتاند؛ هنگام ویرایش فقط همانها مجازند.
|
||||
- همان تگِ ارسال = همان تگِ متنِ ویرایشپذیر = همان تگِ لاگ؛ این سه باید یکی باشند (یک منبع const مشترک).
|
||||
- تست: بعد از migration، یک OTP بفرست (مثلاً `/api/v1/user/send-code`) و در `GET /api/v1/admin/sms/logs?tag=otp` ببین لاگ با تگ `otp` ثبت شده؛ یک ارسال بدون تگ → `global`؛ فیلتر تب لاگها در `/admin/sms` کار کند. سپس متنِ تگ `otp` را از `PATCH /api/v1/admin/sms/messages/otp` ویرایش کن و یک OTP دیگر بفرست تا متن جدید (با placeholder `{code}`) اعمال شود.
|
||||
Reference in New Issue
Block a user