# تسک ۱۷: ماژول پیامک (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);
```
