Files
clinicpro/docs/tasks/task-17-sms/task.md
T
hamed de1a78a235 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.
2026-06-09 22:00:34 +03:30

472 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# تسک ۱۷: ماژول پیامک (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);
```