feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
# تسک ۱۷: ماژول پیامک (SMS)
|
||||
|
||||
## توضیح
|
||||
سیستم پیامک یادآوری نوبت برای بیماران.
|
||||
هر دکتر/کلینیک یک حساب پیامک مستقل دارد که با خرید پیامک شارژ میشود.
|
||||
ارسال پیامک **async** از طریق Symfony Messenger انجام میشود.
|
||||
|
||||
دکتر یا کلینیک میتواند **تمپلیت پیامک سفارشی** برای هر دستهبندی بسازد.
|
||||
ادمین باید تمپلیت را تأیید کند — بعد از تأیید، در ارسال پیامک از آن استفاده میشود.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| GET | `/api/v1/sms/balance` | موجودی حساب پیامک | بله |
|
||||
| POST | `/api/v1/sms/queue` | افزودن پیامک به صف | بله |
|
||||
| GET | `/api/v1/sms/sample-templates` | مشاهده نمونه تمپلیتهای ادمین | بله (Doctor/Clinic) |
|
||||
| GET | `/api/v1/sms/templates` | لیست تمپلیتهای خودم | بله (Doctor/Clinic/Admin) |
|
||||
| POST | `/api/v1/sms/templates` | ساختن تمپلیت جدید | بله (Doctor/Clinic) |
|
||||
| GET | `/api/v1/sms/templates/{uuid}` | جزئیات تمپلیت | بله |
|
||||
| PATCH | `/api/v1/sms/templates/{uuid}` | ویرایش تمپلیت (قبل از ارسال به ادمین) | بله (Owner) |
|
||||
| DELETE | `/api/v1/sms/templates/{uuid}` | حذف تمپلیت | بله (Owner/Admin) |
|
||||
| PATCH | `/api/v1/sms/templates/{uuid}/submit` | ارسال به ادمین برای تأیید | بله (Owner) |
|
||||
| PATCH | `/api/v1/sms/templates/{uuid}/approve` | تأیید تمپلیت | بله (Admin) |
|
||||
| PATCH | `/api/v1/sms/templates/{uuid}/reject` | رد تمپلیت با دلیل | بله (Admin) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۵ (Doctor)، تسک ۰۶ (Clinic)
|
||||
|
||||
## زمان تخمینی
|
||||
۸ تا ۱۰ ساعت
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/sms/balance
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"owner_id": 5,
|
||||
"owner_type": "doctor",
|
||||
"balance": 847
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/sms/queue
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"owner_type": "doctor",
|
||||
"owner_id": 5,
|
||||
"recipients": [
|
||||
{ "mobile": "09120671713", "appointment_uuid": "..." }
|
||||
],
|
||||
"scheduled_at": 1748000000
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 42,
|
||||
"status": "queued",
|
||||
"scheduled_at": 1748000000,
|
||||
"remaining_balance": 846
|
||||
}
|
||||
}
|
||||
|
||||
// Response 402 — موجودی ناکافی
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_SMS_001", "message": "موجودی پیامک کافی نیست" }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SMS Providers — Strategy Pattern
|
||||
|
||||
```php
|
||||
interface SmsProviderInterface
|
||||
{
|
||||
public function send(string $mobile, string $message): bool;
|
||||
public function getName(): string;
|
||||
}
|
||||
|
||||
class KavehNegarProvider implements SmsProviderInterface { ... }
|
||||
class RanginehProvider implements SmsProviderInterface { ... }
|
||||
```
|
||||
|
||||
### Fallback Logic
|
||||
|
||||
```
|
||||
تلاش با Provider اول (KavehNegar):
|
||||
موفق → ثبت log و کسر موجودی
|
||||
ناموفق → تلاش با Provider دوم (Rangineh):
|
||||
موفق → ثبت log و کسر موجودی
|
||||
ناموفق → log خطا، پیامک در صف میماند برای retry
|
||||
```
|
||||
|
||||
**محیط Dev:**
|
||||
```php
|
||||
if ($this->appEnv === 'dev') {
|
||||
// OTP ثابت 12345 — بدون ارسال واقعی
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Symfony Messenger — پیادهسازی Async
|
||||
|
||||
```php
|
||||
// Message
|
||||
class SendSmsMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $mobile,
|
||||
public readonly string $message,
|
||||
public readonly int $smsLogId,
|
||||
) {}
|
||||
}
|
||||
|
||||
// Handler
|
||||
class SendSmsHandler implements MessageHandlerInterface
|
||||
{
|
||||
public function __invoke(SendSmsMessage $message): void
|
||||
{
|
||||
try {
|
||||
$sent = $this->primaryProvider->send($message->mobile, $message->message);
|
||||
|
||||
if (!$sent) {
|
||||
$sent = $this->fallbackProvider->send($message->mobile, $message->message);
|
||||
}
|
||||
|
||||
$this->smsLogRepo->markSent($message->smsLogId, $sent);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('sms.send_failed', [
|
||||
'mobile' => $message->mobile,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e; // Messenger retry میکند
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Retry Config در messenger.yaml:**
|
||||
```yaml
|
||||
framework:
|
||||
messenger:
|
||||
transports:
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
delay: 5000 # 5 ثانیه
|
||||
multiplier: 2 # 5s, 10s, 20s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## قانون کسر موجودی
|
||||
|
||||
```
|
||||
قبل از ارسال:
|
||||
sms_accounts.balance >= count(recipients) ؟
|
||||
خیر → 402
|
||||
بله → ادامه
|
||||
|
||||
بعد از ارسال موفق:
|
||||
UPDATE sms_accounts SET balance = balance - 1 WHERE owner_id = X
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ساختار جدول sms_logs
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT PK | |
|
||||
| owner_type | VARCHAR(10) | `doctor` یا `clinic` |
|
||||
| owner_id | INT | |
|
||||
| mobile | VARCHAR(20) | شماره گیرنده |
|
||||
| message | TEXT | متن پیامک |
|
||||
| provider | VARCHAR(20) | `kavenegar` یا `rangineh` |
|
||||
| status | VARCHAR(10) | `queued` / `sent` / `failed` |
|
||||
| scheduled_at | INT | Unix timestamp |
|
||||
| sent_at | INT NULL | زمان ارسال واقعی |
|
||||
| error | VARCHAR(255) NULL | پیام خطا در صورت شکست |
|
||||
| created_at | INT | |
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- ارسال OTP نیز از همین سرویس استفاده میکند (با `scheduled_at=now()`)
|
||||
- برای OTP، Fallback فوری است — کاربر نمیتواند منتظر retry بماند
|
||||
- موجودی پیامک مستقل از موجودی کیف پول نماینده است
|
||||
|
||||
---
|
||||
|
||||
## سیستم تمپلیت پیامک سفارشی
|
||||
|
||||
### جریان کلی
|
||||
|
||||
```
|
||||
۱. ادمین → چند تمپلیت نمونه آموزشی میسازد (is_sample=true)
|
||||
مثلاً: "یادآوری نوبت — نمونه"
|
||||
|
||||
۲. دکتر/کلینیک → GET /api/v1/sms/sample-templates
|
||||
نمونهها را مشاهده میکند
|
||||
|
||||
۳. دکتر/کلینیک → POST /api/v1/sms/templates
|
||||
تمپلیت خودش را میسازد (status=draft)
|
||||
میتواند از نمونه الهام بگیرد یا از صفر بنویسد
|
||||
|
||||
۴. دکتر/کلینیک → PATCH /api/v1/sms/templates/{uuid}/submit
|
||||
برای تأیید ادمین ارسال میکند (status=pending_approval)
|
||||
|
||||
۵. ادمین → GET /api/v1/sms/templates?status=pending_approval
|
||||
لیست تمپلیتهای در انتظار را میبیند
|
||||
|
||||
۶. ادمین → PATCH /api/v1/sms/templates/{uuid}/approve (status=approved)
|
||||
یا PATCH /api/v1/sms/templates/{uuid}/reject (status=rejected)
|
||||
|
||||
۷. بعد از approve → سیستم SMS این تمپلیت را برای آن دکتر/کلینیک استفاده میکند
|
||||
اگر تمپلیت approved نداشت → از تمپلیت پیشفرض سیستم استفاده میشود
|
||||
```
|
||||
|
||||
### دستهبندی تمپلیتها (category)
|
||||
|
||||
| category | توضیح | متغیرهای مجاز |
|
||||
|----------|-------|--------------|
|
||||
| `appointment_reminder` | یادآوری نوبت | `{patient_name}`, `{doctor_name}`, `{date}`, `{time}`, `{clinic_name}` |
|
||||
| `appointment_confirmed` | تأیید رزرو | `{patient_name}`, `{doctor_name}`, `{date}`, `{time}` |
|
||||
| `appointment_cancelled` | لغو نوبت | `{patient_name}`, `{doctor_name}`, `{date}` |
|
||||
| `appointment_reminder_1h` | یادآوری ۱ ساعت قبل | `{patient_name}`, `{doctor_name}`, `{time}` |
|
||||
| `custom` | پیامک آزاد (دستی) | `{patient_name}`, `{doctor_name}` |
|
||||
|
||||
### ساختار تمپلیت و متغیرها
|
||||
|
||||
```
|
||||
متن نمونه ادمین:
|
||||
"بیمار گرامی {patient_name}، نوبت شما با {doctor_name}
|
||||
در تاریخ {date} ساعت {time} در {clinic_name} تأیید شد."
|
||||
|
||||
دکتر میتواند تغییر دهد:
|
||||
"سلام {patient_name} عزیز! یادآوری نوبت ویزیت با دکتر {doctor_name}
|
||||
تاریخ {date} - ساعت {time}
|
||||
مطب دکتر احمدی، خیابان ولیعصر"
|
||||
```
|
||||
|
||||
متغیرها با `{variable_name}` نشان داده میشوند و هنگام ارسال با مقادیر واقعی جایگزین میشوند.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/sms/sample-templates
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"category": "appointment_reminder",
|
||||
"name": "یادآوری نوبت — نمونه پیشفرض",
|
||||
"content": "بیمار گرامی {patient_name}، نوبت شما با {doctor_name} در تاریخ {date} ساعت {time} در {clinic_name} تأیید شد.",
|
||||
"available_variables": ["{patient_name}", "{doctor_name}", "{date}", "{time}", "{clinic_name}"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/v1/sms/templates
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"category": "appointment_reminder",
|
||||
"name": "یادآوری نوبت — مطب دکتر احمدی",
|
||||
"content": "سلام {patient_name} عزیز! نوبت ویزیت شما با {doctor_name} در تاریخ {date} ساعت {time}. آدرس: خیابان آزادی، مطب طبقه ۲"
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"category": "appointment_reminder",
|
||||
"name": "یادآوری نوبت — مطب دکتر احمدی",
|
||||
"content": "سلام {patient_name} عزیز!...",
|
||||
"status": "draft",
|
||||
"created_at": 1748000000
|
||||
}
|
||||
}
|
||||
|
||||
// Response 422 — متغیر نامعتبر در متن
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_SMS_002", "message": "متغیر {invalid_var} در این دستهبندی مجاز نیست" }]
|
||||
}
|
||||
```
|
||||
|
||||
**اعتبارسنجی هنگام ساختن تمپلیت:**
|
||||
```
|
||||
1. category باید از لیست مجاز باشد
|
||||
2. متغیرهای داخل {} فقط از لیست available_variables مجاز category باشند
|
||||
3. طول محتوا: حداکثر 500 کاراکتر
|
||||
4. هر دکتر/کلینیک حداکثر 3 تمپلیت فعال approved برای هر category
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/sms/templates/{uuid}/submit
|
||||
|
||||
```json
|
||||
// Request — بدون body
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"status": "pending_approval",
|
||||
"submitted_at": 1748000000
|
||||
}
|
||||
}
|
||||
|
||||
// Response 422 — تمپلیت قبلاً submitted یا approved شده
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_SMS_003", "message": "تمپلیت قبلاً برای بررسی ارسال شده است" }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/sms/templates/{uuid}/approve (Admin)
|
||||
|
||||
```json
|
||||
// Request — بدون body
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"status": "approved",
|
||||
"approved_at": 1748000000,
|
||||
"approved_by": { "uuid": "...", "name": "ادمین سیستم" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/sms/templates/{uuid}/reject (Admin)
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد. لطفاً نام کامل بیمار را حذف کنید."
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"status": "rejected",
|
||||
"rejection_reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد...",
|
||||
"rejected_at": 1748000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
بعد از reject، صاحب تمپلیت میتواند تمپلیت را ویرایش کند (status → draft) و مجدداً submit کند.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/sms/templates
|
||||
|
||||
```json
|
||||
// برای دکتر/کلینیک — فقط تمپلیتهای خودش
|
||||
// برای ادمین — همه تمپلیتها با فیلتر
|
||||
|
||||
// Query params:
|
||||
// ?status=pending_approval ← ادمین برای بررسی
|
||||
// ?category=appointment_reminder
|
||||
// ?owner_type=doctor&owner_id=5
|
||||
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"category": "appointment_reminder",
|
||||
"name": "یادآوری نوبت — مطب دکتر احمدی",
|
||||
"content": "سلام {patient_name} عزیز!...",
|
||||
"status": "approved",
|
||||
"owner": { "type": "doctor", "name": "دکتر احمدی" },
|
||||
"approved_at": 1748000000,
|
||||
"created_at": 1748000000
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### منطق انتخاب تمپلیت هنگام ارسال پیامک
|
||||
|
||||
```php
|
||||
// در SmsService::getTemplateFor(ownerId, ownerType, category)
|
||||
public function getTemplateFor(int $ownerId, string $ownerType, string $category): SmsTemplate
|
||||
{
|
||||
// ابتدا تمپلیت approved خاص آن دکتر/کلینیک
|
||||
$custom = $this->templateRepo->findApproved($ownerId, $ownerType, $category);
|
||||
|
||||
if ($custom) {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
// اگر نداشت → تمپلیت نمونه پیشفرض ادمین
|
||||
return $this->templateRepo->findDefaultSample($category);
|
||||
}
|
||||
|
||||
// رندر محتوای نهایی با جایگزینی متغیرها
|
||||
public function render(SmsTemplate $template, array $vars): string
|
||||
{
|
||||
return strtr($template->getContent(), array_combine(
|
||||
array_map(fn($k) => '{' . $k . '}', array_keys($vars)),
|
||||
array_values($vars)
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### جدول sms_templates
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT PK | |
|
||||
| uuid | VARCHAR(36) | |
|
||||
| owner_type | VARCHAR(10) | `doctor` / `clinic` / `admin` |
|
||||
| owner_id | INT NULL | NULL برای نمونههای ادمین |
|
||||
| category | VARCHAR(30) | appointment_reminder / ... |
|
||||
| name | VARCHAR(100) | نام قابل خواندن |
|
||||
| content | TEXT | متن با متغیرها |
|
||||
| is_sample | TINYINT(1) | 1 برای نمونههای ادمین |
|
||||
| status | VARCHAR(20) | `draft` / `pending_approval` / `approved` / `rejected` |
|
||||
| rejection_reason | TEXT NULL | دلیل رد ادمین |
|
||||
| approved_by | INT NULL | user_id ادمین تأییدکننده |
|
||||
| approved_at | INT NULL | |
|
||||
| submitted_at | INT NULL | |
|
||||
| created_at | INT | |
|
||||
| updated_at | INT | |
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_sms_tmpl_owner ON sms_templates(owner_type, owner_id);
|
||||
CREATE INDEX idx_sms_tmpl_status ON sms_templates(status);
|
||||
CREATE INDEX idx_sms_tmpl_cat ON sms_templates(category);
|
||||
```
|
||||
Reference in New Issue
Block a user