From 1bb9cedd226a0296b59f50831d229eed83419ece Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 4 Jul 2026 11:01:54 +0330 Subject: [PATCH] feat(auth): add optional domain parameter to send OTP code for site personalization --- .claude/prompt/otp-sms-site-name.md | 149 +++++++++++++++++++++++++ docs/api/auth.md | 4 +- docs/api/sms.md | 4 +- src/Auth/Controller/AuthController.php | 3 +- src/Auth/Service/OtpService.php | 21 +++- src/Sms/Entity/SmsMessageTemplate.php | 2 +- 6 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 .claude/prompt/otp-sms-site-name.md diff --git a/.claude/prompt/otp-sms-site-name.md b/.claude/prompt/otp-sms-site-name.md new file mode 100644 index 00000000..bb4914e3 --- /dev/null +++ b/.claude/prompt/otp-sms-site-name.md @@ -0,0 +1,149 @@ +# افزودن اسم سایت شهر به پیامک کد تأیید (OTP) — به‌صورت اختیاری + +## پروژه + +`clinicpro` (Backend) — **cross-repo**؛ پرامپت همتای frontend: `nobat724_front/.claude/prompt/otp-sms-site-name.md` + +## زمینه + +سایت عمومی چند-دامنه‌ای است (هر شهر یک دامنه: `yasuj-nobat.ir`، `ahvaz-nobat.ir`، ...). هر شهر در جدول `cities` فیلد `site_name` دارد (مثل «یاسوج نوبت»). الان پیامک کد تأیید ورود ثابت است: `کد تأیید شما: {code}` و اسم سایت شهر ندارد. + +هدف: بتوان **اختیاری** اسم سایتِ شهرِ درخواست‌کننده را در پیامک OTP نمایش داد — مثلاً «کد تأیید شما در یاسوج نوبت: ۱۲۳۴۵». اختیاری‌بودن از طریق **قالب قابل‌ویرایش پنل SMS** کنترل می‌شود: اگر ادمین `{site}` را در متن قالب بگذارد نمایش داده می‌شود، نگذارد نمی‌شود. + +## مشکل / هدف + +- درخواست `send-code` از دامنه‌ی شهر به API (`api.clinic-pro.ir`) می‌رسد → `Host` = دامنه‌ی API، نه شهر. پس backend خودش شهر را نمی‌فهمد. +- **راه‌حل:** frontend دامنه‌ی خودش را در body می‌فرستد؛ backend از روی `domain` در جدول `cities` → `site_name` را پیدا می‌کند و به‌عنوان متغیر `{site}` به قالب پیامک تزریق می‌کند. اگر شهری پیدا نشد یا `domain` نیامد → fallback به برند پیش‌فرض «نوبت ۷۲۴». +- متغیر `{site}` همیشه مقداردهی می‌شود؛ استفاده یا عدم‌استفاده‌اش به قالب بستگی دارد (اختیاری). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Auth/Controller/AuthController.php` | endpoint `POST /api/v1/user/send-code` — خواندن `domain` اختیاری از body | +| `src/Auth/Service/OtpService.php` | ساخت و ارسال پیامک OTP — resolve اسم سایت و تزریق `{site}` | +| `src/Location/Repository/CityRepository.php` | lookup شهر بر اساس `domain` | +| `src/Location/Entity/City.php` | دارای `getDomain()` و `getSiteName()` (آماده، تغییر نمی‌خواهد) | +| `src/Sms/Entity/SmsMessageTemplate.php` | ثبت `{site}` به‌عنوان متغیر مجاز قالب OTP | +| `docs/api/auth.md`, `docs/api/sms.md` | مستندسازی | + +## وضعیت فعلی + +`OtpService::sendCode` (src/Auth/Service/OtpService.php): + +```php +public function sendCode(string $mobile): string +{ + $uuid = Uuid::v4()->toRfc4122(); + $code = $this->appEnv === 'dev' ? '12345' : str_pad((string) random_int(10000, 99999), 5, '0', STR_PAD_LEFT); + // ... cache set ... + if ($this->appEnv !== 'dev') { + $message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code]); + $this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP); + } + return $uuid; +} +``` + +`AuthController::sendCode` (src/Auth/Controller/AuthController.php:136): + +```php +$data = json_decode($request->getContent(), true) ?? []; +$mobile = trim($data['mobile'] ?? ''); +// ... validation + rate limit ... +$uuid = $this->otpService->sendCode($mobile); +``` + +قالب پیش‌فرض OTP (src/Sms/Entity/SmsMessageTemplate.php): + +```php +SmsLog::TAG_OTP => [ + 'title' => 'کد تأیید ورود', + 'body' => 'کد تأیید شما: {code}', + 'variables' => ['code'], +], +``` + +resolver (src/Sms/Service/SmsTextResolver.php) هر `{key}` را با str_replace جایگزین می‌کند — پس `{site}` بدون تغییر resolver کار می‌کند. + +## وظایف + +### ۱. `OtpService` — resolve اسم سایت و تزریق `{site}` + +- `CityRepository` را به constructor تزریق کن. +- امضای متد را به `sendCode(string $mobile, ?string $domain = null)` تغییر بده (پارامتر اختیاری → سازگاری عقب‌رو). +- اسم سایت را resolve کن و به متغیرهای قالب اضافه کن: + +```php +public function sendCode(string $mobile, ?string $domain = null): string +{ + // ... code + cache بدون تغییر ... + if ($this->appEnv !== 'dev') { + $site = $this->resolveSiteName($domain); + $message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]); + $this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP); + } + return $uuid; +} + +private function resolveSiteName(?string $domain): string +{ + $default = 'نوبت ۷۲۴'; + if (!$domain) { + return $default; + } + $domain = strtolower(trim(preg_replace('#^https?://#', '', $domain))); + $domain = preg_replace('#/.*$#', '', $domain); // strip path + $city = $this->cityRepo->findOneBy(['domain' => $domain]); + return $city?->getSiteName() ?: $default; +} +``` + +- **نکته:** `{site}` همیشه مقدار دارد؛ اگر قالب فعلی `{site}` نداشته باشد، این متغیر بی‌اثر است (اختیاری‌بودن حفظ می‌شود). + +### ۲. `AuthController::sendCode` — خواندن `domain` اختیاری از body + +```php +$mobile = trim($data['mobile'] ?? ''); +$domain = isset($data['domain']) ? trim((string) $data['domain']) : null; +// ... validation + rate limit بدون تغییر ... +$uuid = $this->otpService->sendCode($mobile, $domain ?: null); +``` + +- `domain` اختیاری است؛ اگر نیامد، رفتار قبلی حفظ می‌شود. +- روی `domain` **validation سخت‌گیرانه لازم نیست** (فقط برای lookup استفاده می‌شود، نه اجرا)، اما trim و طول محدود (مثلاً حداکثر ۲۵۳ کاراکتر) اعمال کن. + +### ۳. `SmsMessageTemplate` — ثبت `{site}` به‌عنوان متغیر مجاز OTP + +قالب پیش‌فرض را **بدون** `{site}` نگه دار (opt-in)، اما `site` را به لیست `variables` اضافه کن تا در پنل ویرایش قالب به‌عنوان متغیر قابل‌استفاده نمایش داده شود: + +```php +SmsLog::TAG_OTP => [ + 'title' => 'کد تأیید ورود', + 'body' => 'کد تأیید شما: {code}', // پیش‌فرض بدون site + 'variables' => ['code', 'site'], // site به‌عنوان متغیر اختیاری در پنل +], +``` + +- این‌طوری ادمین در پنل SMS می‌تواند body را به `کد تأیید شما در {site}: {code}` تغییر دهد تا اسم سایت نمایش داده شود — یا ندهد. + +### ۴. مستندسازی + +- `docs/api/auth.md`: در `POST /api/v1/user/send-code`، فیلد **اختیاری** `domain` را به request body اضافه کن (نوع string، توضیح: دامنه‌ی شهرِ درخواست‌کننده برای شخصی‌سازی متن پیامک؛ اگر نیامد از برند پیش‌فرض استفاده می‌شود). نمونه request/response را به‌روز کن. +- `docs/api/sms.md`: متغیر جدید `{site}` را برای قالب `OTP` مستند کن (مقدار = `site_name` شهرِ دامنه یا «نوبت ۷۲۴»). + +## نکات مهم + +- **backward-compatible**: هر دو `domain` (body) و پارامتر `sendCode` اختیاری‌اند؛ کلاینت‌های فعلی (پنل ادمین clinicpro، لاگین بدون domain) بدون تغییر کار می‌کنند و برند پیش‌فرض را می‌گیرند. +- محیط `dev`: پیامک ارسال نمی‌شود (کد ثابت `12345`) — این مسیر دست‌نخورده بماند. +- `{site}` هرگز خالی نباشد (fallback «نوبت ۷۲۴») تا اگر ادمین `{site}` را در قالب گذاشت، متن ناقص «... در : ...» تولید نشود. +- lookup دامنه: مقدار ارسالی frontend ممکن است `yasuj-nobat.ir` یا با `www.`/`https://` باشد — در `resolveSiteName` normalize کن (حذف پروتکل و path). اگر لازم شد `www.` را هم strip کن، اما مقادیر `cities.domain` بدون `www` ذخیره شده‌اند (مثل `yasuj-nobat.ir`). +- **پیامک OTP با پترن اپراتور:** provider پترن `{site}` را پشتیبانی می‌کند (تأییدشده). مطمئن شو ترتیب/نام متغیرها با پترن مصوب سامانه‌ی پیامکی هماهنگ است. +- تست: + ```bash + ddev exec php -l src/Auth/Service/OtpService.php + ddev exec php -l src/Auth/Controller/AuthController.php + ddev exec php bin/console cache:clear + # ارسال با domain و بدون domain را در محیط staging بررسی کن (dev پیامک نمی‌فرستد) + ``` +- بعد از تغییر API، `docs/api/auth.md` و `docs/api/sms.md` را در همین session به‌روز کن (قانون استاندینگ پروژه). diff --git a/docs/api/auth.md b/docs/api/auth.md index 13a3070e..93e7108f 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -14,13 +14,15 @@ Send OTP code to mobile number. ### Request Body ```json { - "mobile": "09123456789" + "mobile": "09123456789", + "domain": "yasuj-nobat.ir" } ``` | Field | Type | Required | Validation | |-------|------|----------|------------| | `mobile` | string | ✅ | Format: `09XXXXXXXXX` (11 digits) | +| `domain` | string | ⬜ | دامنه‌ی شهرِ درخواست‌کننده (multi-domain). اگر ارسال شود، `site_name` شهرِ متناظر در جدول `cities` پیدا شده و به‌عنوان متغیر `{site}` در متن پیامک OTP قابل استفاده است (شخصی‌سازی متن). اگر نیامد یا شهر پیدا نشد → مقدار پیش‌فرض «کلینیک پرو». حداکثر ۲۵۳ کاراکتر. | ### Response `200` ```json diff --git a/docs/api/sms.md b/docs/api/sms.md index 60eeb74f..3aae5b93 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -484,6 +484,8 @@ Updated template with `status: "rejected"`. > تگ‌ها: `otp`، `payment`، `clinic_invitation`، `pre_registration`، `notification_mobile`، `welcome`، `secretary`، `doctor_appointment`. (پیامک قالبیِ کاربر با تگ `user_template` جداگانه از طریق `POST /api/v1/sms/template` مدیریت می‌شود.) > +> تگ `otp`: کد تأیید ورود. placeholderها: `{code}` (کد ۵ رقمی)، `{site}` (**اختیاری** — اسم سایتِ شهرِ درخواست‌کننده برای شخصی‌سازی متن). مقدار `{site}` از فیلد `domain` در `POST /api/v1/user/send-code` گرفته می‌شود: `site_name` شهرِ متناظر در جدول `cities`؛ اگر `domain` نیامد یا شهر پیدا نشد → «کلینیک پرو». برای فعال‌کردن اسم سایت کافی است `{site}` را در body قالب بگذارید (مثلاً `کد تأیید شما در {site}: {code}`)؛ اگر نگذارید، اسم سایت نمایش داده نمی‌شود. متن از قالب DB (fallback به `DEFAULTS`). +> > تگ `welcome`: پیامک خوش‌آمد که هنگام افزودن پزشک/کلینیک توسط نماینده (`POST /api/v1/representation/doctor|clinic`) به‌صورت async به موبایل پزشک/مالک ارسال می‌شود. متن فعلاً ثابت است (نام + `site_name`)، نه از قالب DB. > > تگ `secretary`: پیامک خوش‌آمد که هنگام تعریف منشی جدید (`POST /api/v1/secretary`) به‌صورت async به موبایل منشی ارسال می‌شود. placeholderها: `{owner}` (نام دکتر یا کلینیک)، `{username}` (موبایل منشی)، `{link}` (لینک ورود). متن از قالب DB می‌آید (fallback به پیش‌فرض `SmsMessageTemplate::DEFAULTS`). @@ -506,7 +508,7 @@ Updated template with `status: "rejected"`. "tag": "otp", "title": "کد تأیید ورود", "body": "کد تأیید شما: {code}", - "variables": ["code"], + "variables": ["code", "site"], "updated_at": 1718000000 } ] diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 87408323..b9be6879 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -142,6 +142,7 @@ class AuthController extends BaseController $data = json_decode($request->getContent(), true) ?? []; $mobile = trim($data['mobile'] ?? ''); + $domain = isset($data['domain']) ? substr(trim((string) $data['domain']), 0, 253) : null; if (!preg_match('/^09[0-9]{9}$/', $mobile)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت شماره موبایل نادرست است', 422, 'mobile'); @@ -154,7 +155,7 @@ class AuthController extends BaseController return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429); } - $uuid = $this->otpService->sendCode($mobile); + $uuid = $this->otpService->sendCode($mobile, $domain ?: null); return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']); } diff --git a/src/Auth/Service/OtpService.php b/src/Auth/Service/OtpService.php index 062867bc..c3a851a9 100644 --- a/src/Auth/Service/OtpService.php +++ b/src/Auth/Service/OtpService.php @@ -2,6 +2,7 @@ namespace App\Auth\Service; +use App\Location\Repository\CityRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; use App\Sms\Entity\SmsLog; @@ -16,6 +17,7 @@ class OtpService private readonly CacheInterface $cache, private readonly SmsService $sms, private readonly SmsTextResolver $smsText, + private readonly CityRepository $cityRepo, private readonly int $otpTtl = 1200, private readonly string $appEnv = 'dev', ) {} @@ -56,7 +58,7 @@ class OtpService return $mobile; } - public function sendCode(string $mobile): string + public function sendCode(string $mobile, ?string $domain = null): string { $uuid = Uuid::v4()->toRfc4122(); $code = $this->appEnv === 'dev' @@ -69,13 +71,28 @@ class OtpService $this->cache->save($item); if ($this->appEnv !== 'dev') { - $message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code]); + $site = $this->resolveSiteName($domain); + $message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]); $this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP); } return $uuid; } + private function resolveSiteName(?string $domain): string + { + $default = 'کلینیک پرو'; + if (!$domain) { + return $default; + } + + $domain = strtolower(trim((string) preg_replace('#^https?://#', '', $domain))); + $domain = (string) preg_replace('#/.*$#', '', $domain); + $domain = (string) preg_replace('#^www\.#', '', $domain); + + return $this->cityRepo->findOneBy(['domain' => $domain])?->getSiteName() ?: $default; + } + public function verifyCode(string $uuid, string $submittedCode): array { $item = $this->cache->getItem($this->key($uuid)); diff --git a/src/Sms/Entity/SmsMessageTemplate.php b/src/Sms/Entity/SmsMessageTemplate.php index 1bb33a6c..3588a331 100644 --- a/src/Sms/Entity/SmsMessageTemplate.php +++ b/src/Sms/Entity/SmsMessageTemplate.php @@ -21,7 +21,7 @@ class SmsMessageTemplate SmsLog::TAG_OTP => [ 'title' => 'کد تأیید ورود', 'body' => 'کد تأیید شما: {code}', - 'variables' => ['code'], + 'variables' => ['code', 'site'], ], SmsLog::TAG_PAYMENT => [ 'title' => 'تأیید پرداخت و نوبت',