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

14 KiB
Raw Blame History

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

{
  "success": true,
  "data": {
    "owner_id": 5,
    "owner_type": "doctor",
    "balance": 847
  }
}

POST /api/v1/sms/queue

// 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

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:

if ($this->appEnv === 'dev') {
    // OTP ثابت 12345 — بدون ارسال واقعی
    return true;
}

Symfony Messenger — پیاده‌سازی Async

// 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:

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

{
  "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

// 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

// 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)

// 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)

// Request
{
  "reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد. لطفاً نام کامل بیمار را حذف کنید."
}

// Response 200
{
  "success": true,
  "data": {
    "uuid": "...",
    "status": "rejected",
    "rejection_reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد...",
    "rejected_at": 1748000000
  }
}

بعد از reject، صاحب تمپلیت می‌تواند تمپلیت را ویرایش کند (status → draft) و مجدداً submit کند.


GET /api/v1/sms/templates

// برای دکتر/کلینیک — فقط تمپلیت‌های خودش
// برای ادمین — همه تمپلیت‌ها با فیلتر

// 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 }
}

منطق انتخاب تمپلیت هنگام ارسال پیامک

// در 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
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);