feat: implement OTP sending via Kavenegar VerifyLookup and add image cropping modal

- Added support for sending OTP messages using Kavenegar's VerifyLookup method, ensuring compliance with specified token formatting and template usage.
- Updated OtpService to handle new template parameters and fallback mechanisms.
- Introduced ImageCropModal component for cropping images with a user-friendly interface.
- Created utility function for cropping images and generating downloadable files.
This commit is contained in:
hamed
2026-07-04 21:50:55 +03:30
parent 1bb9cedd22
commit 6ade17a5b4
10 changed files with 822 additions and 2185 deletions
+191
View File
@@ -0,0 +1,191 @@
# ارسال پیامک OTP فقط از طریق Kavenegar VerifyLookup (پترن)
## پروژه
`clinicpro` (Backend — SMS/Auth)
## زمینه
پیامک کد تأیید ورود (OTP) الان با متد **متن‌آزاد** `send()` کاوه‌نگار (`/sms/send.json`) ارسال می‌شود. کاوه‌نگار برای ارسال کد تأیید، متد اختصاصی **VerifyLookup** (`/verify/lookup.json`) دارد که با **پترن مصوب** کار می‌کند، روی خطوط اشتراکی هم تحویل مطمئن‌تری دارد و برای OTP توصیه/لازم است. هدف: **فقط OTP** از این به بعد از طریق Lookup ارسال شود (بقیه پیامک‌ها — welcome، secretary، doctor_appointment، user_template — دست‌نخورده بمانند).
## اسپک Kavenegar VerifyLookup (از داکیومنت رسمی)
- **Endpoint:** `https://api.kavenegar.com/v1/{API-KEY}/verify/lookup.json` (GET/POST)
- **پارامترهای اجباری:** `receptor` (موبایل)، `token` (مقدار کد؛ max 100؛ **بدون فاصله**؛ بدون آندرلاین/خط جدید)، `template` (نام پترن مصوب در پنل)
- **پارامترهای اختیاری توکن و قانون فاصله (بحرانی):**
| توکن | فاصله |
|------|-------|
| `token`, `token2`, `token3` | **بدون فاصله** (رد می‌شود) |
| `token10` | حداکثر **۵ فاصله** مجاز |
| `token20` | حداکثر **۸ فاصله** مجاز |
- `type`: `sms` (پیش‌فرض) یا `call`.
- **پترن باید از قبل در پنل کاوه‌نگار تأیید شده باشد**؛ متنِ پیام روی پنل ثابت است (نه از DB).
- نیازمند اشتراک advanced.
**پیامد:** کد ۵ رقمی (بدون فاصله) → `token`. اسم سایت مثل «یاسوج نوبت» (۱ فاصله) → باید در **`token10`** برود (نه `token`/`token2`/`token3` که فاصله را رد می‌کنند).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Auth/Service/OtpService.php` | ساخت و dispatch پیامک OTP — باید به Lookup سوییچ شود |
| `src/Sms/Service/SmsService.php` | `dispatchAsync(...templateCode, templateVars...)` → اگر `templateCode` باشد `sendTemplate()` صدا می‌زند |
| `src/Sms/Provider/KavehNegarProvider.php` | `sendTemplate()``/verify/lookup.json`؛ map توکن باید نام‌دار شود (برای `token10`) |
| `.env` + `config/services.yaml` | افزودن `KAVENEGAR_OTP_TEMPLATE` و bind به OtpService |
| `docs/api/sms.md` | مستندسازی سوییچ OTP به Lookup |
## وضعیت فعلی
**OtpService::sendCode** (متن‌آزاد، بدون templateCode):
```php
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);
}
```
Constructor فعلی:
```php
public function __construct(
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',
) {}
```
**SmsService::dispatchAsync / sendNow** (مسیر انتخاب send vs lookup):
```php
public function dispatchAsync(
string $mobile, string $message, string $provider = 'kavenegar',
?string $templateUuid = null, array $templateVars = [],
?string $templateCode = null, string $tag = SmsLog::TAG_GLOBAL,
): void { /* dispatch SendSmsMessage */ }
// sendNow:
$success = ($msg->templateCode !== null)
? $provider->sendTemplate($msg->mobile, $msg->templateCode, $msg->templateVars)
: $provider->send($msg->mobile, $msg->message);
```
**KavehNegarProvider::sendTemplate** (map ترتیبی فعلی — فاصله را در token2/3 می‌گذارد که رد می‌شود):
```php
public function sendTemplate(string $mobile, string $templateCode, array $vars): bool
{
try {
$params = ['receptor' => $mobile, 'template' => $templateCode];
foreach (array_values($vars) as $i => $v) {
$params['token' . ($i > 0 ? $i + 1 : '')] = $v;
}
$resp = $this->httpClient->request('POST',
self::BASE . '/' . $this->key() . '/verify/lookup.json', [
'body' => http_build_query($params), 'timeout' => 10,
]);
$data = $resp->toArray();
return ($data['return']['status'] ?? 0) === 200;
} catch (\Throwable $e) { /* log; return false */ }
}
```
**caller دیگر `sendTemplate`:** فقط `POST /api/v1/sms/send-template` در `src/Sms/Controller/SmsController.php:488` که `$vars` را با **کلیدهای معنایی** (مثل `name`,`date`) و وابسته به **ترتیب** پاس می‌دهد. پس map ترتیبی نباید برای این caller بشکند.
**.env فعلی:**
```
OTP_TTL=1200
KAVENEGAR_API_KEY=change_me
```
## وظایف
### ۱. `KavehNegarProvider::sendTemplate` — پشتیبانی توکنِ نام‌دار (backward-safe)
اگر همه‌ی کلیدهای `$vars` نام slot معتبر کاوه‌نگار باشند (`token`,`token2`,`token3`,`token10`,`token20`) → همان‌ها را مستقیم استفاده کن (تا اسم سایتِ فاصله‌دار در `token10` برود). در غیر این صورت (کلیدهای معنایی/لیستی) → همان map ترتیبی فعلی (برای caller `send-template`).
```php
$params = ['receptor' => $mobile, 'template' => $templateCode];
$slots = ['token', 'token2', 'token3', 'token10', 'token20'];
if ($vars !== [] && array_keys($vars) !== range(0, count($vars) - 1)
&& array_diff(array_keys($vars), $slots) === []) {
foreach ($vars as $slot => $v) { $params[$slot] = $v; }
} else {
foreach (array_values($vars) as $i => $v) {
$params['token' . ($i > 0 ? $i + 1 : '')] = $v;
}
}
```
### ۲. `.env` + `config/services.yaml` — نام پترن OTP
`.env` (کاربر مقدار واقعی پترن مصوب را می‌گذارد):
```
KAVENEGAR_OTP_TEMPLATE=
```
`config/services.yaml` روی سرویس `OtpService`:
```yaml
App\Auth\Service\OtpService:
arguments:
$otpTtl: '%env(int:OTP_TTL)%'
$appEnv: '%kernel.environment%'
$otpTemplate: '%env(default::KAVENEGAR_OTP_TEMPLATE)%'
```
### ۳. `OtpService` — dispatch از طریق Lookup
- پارامتر constructor جدید `?string $otpTemplate = null` **بعد از** پارامترهای بدون‌دیفالت و کنار `$appEnv` (ترتیب معتبر PHP؛ همه دیفالت‌دار در انتها).
- در `sendCode`، وقتی پترن ست است → با `templateCode` و توکن‌های نام‌دار dispatch کن:
```php
if ($this->appEnv !== 'dev') {
$site = $this->resolveSiteName($domain);
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]); // فقط برای لاگ SmsLog
if ($this->otpTemplate) {
$this->sms->dispatchAsync(
$mobile, $message,
templateCode: $this->otpTemplate,
templateVars: ['token' => $code, 'token10' => $site],
tag: SmsLog::TAG_OTP,
);
} else {
// fallback متن‌آزاد فقط وقتی پترن ست نشده (مثلاً محیط توسعه)
$this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP);
}
}
```
- `token` = کد ۵ رقمی (بدون فاصله ✓). `token10` = اسم سایت (تا ۵ فاصله مجاز ✓).
- اگر پترن مصوب فقط `%token` داشته باشد، `token10` اضافی توسط کاوه‌نگار نادیده گرفته می‌شود (بی‌خطر) — اسم سایت فقط وقتی نمایش داده می‌شود که پترن `%token10` هم داشته باشد.
### ۴. مستندسازی `docs/api/sms.md`
- در بخش «متن ویرایش‌پذیر پیامک‌های سیستمی»، تگ `otp`: تصریح کن که **ارسال OTP از طریق Kavenegar VerifyLookup** انجام می‌شود (نه `send` متن‌آزاد) وقتی `KAVENEGAR_OTP_TEMPLATE` ست باشد؛ متنِ پیام از **پترن مصوب کاوه‌نگار** می‌آید نه از body قابل‌ویرایش DB (body صرفاً برای لاگ `SmsLog`).
- map توکن‌ها را مستند کن: `token` = کد، `token10` = اسم سایت (فاصله‌دار).
- الزام `.env`: `KAVENEGAR_OTP_TEMPLATE` باید نام پترن مصوب باشد؛ بدون آن، fallback به متن‌آزاد.
## نکات مهم
- **فقط OTP** به Lookup می‌رود؛ مسیر `send()` متن‌آزاد برای بقیه‌ی تگ‌ها و caller `send-template` دست‌نخورده بماند.
- **قانون فاصله** رعایت شود: کد → `token`؛ هر مقدار فاصله‌دار (اسم سایت) → `token10`. هرگز اسم فاصله‌دار در `token`/`token2`/`token3` نگذار.
- **پترن باید در پنل کاوه‌نگار مصوب باشد** و ساختار توکنش با map کد بخواند (`%token` برای کد، در صورت نیاز `%token10` برای سایت). این خارج از کد است و باید توسط صاحب حساب انجام شود.
- نیازمند **اشتراک advanced** کاوه‌نگار.
- **backward-safe**: تغییر `sendTemplate` نباید caller `send-template` (کلیدهای معنایی/ترتیبی) را بشکند — با شرط «همه کلیدها slot معتبرند» تضمین شود.
- **cache**: بعد از تغییر constructor `OtpService` و bind جدید، `cache:clear` برای **هر دو محیط** `dev` و `test` لازم است (وگرنه کانتینر کامپایل‌شده‌ی قدیمی TypeError می‌دهد). worker پیامک (`messenger:consume async`) هم باید ری‌استارت شود.
- **تست**:
```bash
ddev exec php -l src/Auth/Service/OtpService.php
ddev exec php -l src/Sms/Provider/KavehNegarProvider.php
ddev exec php bin/console cache:clear
ddev exec php bin/console cache:clear --env=test
ddev exec php bin/console lint:container
ddev exec php bin/phpunit tests/Auth/SendCodeMobileRateLimitTest.php
```
(تست واقعی ارسال Lookup نیاز به API key و پترن مصوب دارد؛ در `dev`/`test` پیامک ارسال نمی‌شود.)
- بعد از تغییر، `docs/api/sms.md` را در همان session به‌روز کن (قانون استاندینگ پروژه).
@@ -0,0 +1,75 @@
import { useCallback, useState } from 'react';
import Cropper, { type Area } from 'react-easy-crop';
import { getCroppedImage } from '../lib/cropImage';
interface ImageCropModalProps {
src: string;
fileName: string;
onCancel: () => void;
onConfirm: (file: File) => void;
}
export default function ImageCropModal({ src, fileName, onCancel, onConfirm }: ImageCropModalProps) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [area, setArea] = useState<Area | null>(null);
const [processing, setProcessing] = useState(false);
const onCropComplete = useCallback((_: Area, pixels: Area) => setArea(pixels), []);
const handleConfirm = async () => {
if (!area) return;
setProcessing(true);
try {
const file = await getCroppedImage(src, area, fileName);
onConfirm(file);
} finally {
setProcessing(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" dir="rtl">
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 dark:border-gray-800">
<p className="text-base font-bold text-slate-800 dark:text-slate-100">برش تصویر</p>
<button onClick={onCancel}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 text-xl leading-none">×</button>
</div>
<div className="relative w-full h-72 bg-slate-100 dark:bg-gray-800">
<Cropper
image={src}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
<div className="px-5 py-4 space-y-4">
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500 dark:text-slate-400 shrink-0">بزرگنمایی</span>
<input type="range" min={1} max={3} step={0.01} value={zoom}
onChange={e => setZoom(Number(e.target.value))}
className="w-full accent-indigo-600" />
</div>
<div className="flex items-center justify-end gap-3">
<button onClick={onCancel} disabled={processing}
className="px-4 py-2 rounded-xl text-sm font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-gray-800 hover:bg-slate-200 dark:hover:bg-gray-700 disabled:opacity-50">
انصراف
</button>
<button onClick={handleConfirm} disabled={processing || !area}
className="px-4 py-2 rounded-xl text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50">
{processing ? 'در حال برش...' : 'ذخیره'}
</button>
</div>
</div>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { Area } from 'react-easy-crop';
function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (err) => reject(err));
image.setAttribute('crossOrigin', 'anonymous');
image.src = url;
});
}
export async function getCroppedImage(
src: string,
area: Area,
fileName: string,
outputSize = 512,
): Promise<File> {
const image = await createImage(src);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('عدم دسترسی به canvas');
canvas.width = outputSize;
canvas.height = outputSize;
ctx.drawImage(
image,
area.x,
area.y,
area.width,
area.height,
0,
0,
outputSize,
outputSize,
);
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('خطا در برش تصویر'))),
'image/jpeg',
0.92,
);
});
const baseName = fileName.replace(/\.[^./\\]+$/, '') || 'avatar';
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
}
+22 -1
View File
@@ -29,6 +29,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import GlobalSearchableSelect from '../components/ui/SearchableSelect';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import ImageCropModal from '../components/ImageCropModal';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -2335,6 +2336,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
const [toggleConfirm, setToggleConfirm] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [uploadingImg, setUploadingImg] = useState(false);
const [cropState, setCropState] = useState<{ src: string; name: string } | null>(null);
const [addrModalOpen, setAddrModalOpen] = useState(false);
const [editingAddr, setEditingAddr] = useState<AddressData | null>(null);
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(null);
@@ -2475,6 +2477,17 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
// ── Image upload ──
const openCrop = useCallback((file: File) => {
setCropState({ src: URL.createObjectURL(file), name: file.name });
}, []);
const closeCrop = useCallback(() => {
setCropState(prev => {
if (prev) URL.revokeObjectURL(prev.src);
return null;
});
}, []);
const handleImageUpload = useCallback(async (file: File) => {
setUploadingImg(true);
try {
@@ -2567,7 +2580,15 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<div className="flex flex-col items-center gap-1.5">
<DoctorAvatar name={doctor.name} img={mainImage} idx={idNum}
onUpload={primaryRole !== 'clinic' && !isReadOnly ? handleImageUpload : undefined} uploading={uploadingImg} />
onUpload={primaryRole !== 'clinic' && !isReadOnly ? openCrop : undefined} uploading={uploadingImg} />
{cropState && (
<ImageCropModal
src={cropState.src}
fileName={cropState.name}
onCancel={closeCrop}
onConfirm={(file) => { closeCrop(); handleImageUpload(file); }}
/>
)}
{primaryRole !== 'clinic' && !isReadOnly && (
<span className="text-[10px] text-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span>
)}
+1
View File
@@ -56,6 +56,7 @@ services:
arguments:
$otpTtl: '%env(int:OTP_TTL)%'
$appEnv: '%kernel.environment%'
$otpTemplate: '%env(default::KAVENEGAR_OTP_TEMPLATE)%'
App\Auth\Service\TokenService:
arguments:
+3 -1
View File
@@ -484,7 +484,9 @@ 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`).
> تگ `otp`: کد تأیید ورود. مقدار `site` از فیلد `domain` در `POST /api/v1/user/send-code` گرفته می‌شود: `site_name` شهرِ متناظر در جدول `cities`؛ اگر `domain` نیامد یا شهر پیدا نشد → «کلینیک پرو».
>
> **ارسال OTP از طریق Kavenegar VerifyLookup (پترن):** اگر متغیر محیطی `KAVENEGAR_OTP_TEMPLATE` (نام پترن مصوب پنل کاوه‌نگار) ست باشد، OTP با `verify/lookup.json` ارسال می‌شود — **متنِ پیام از پترن ثابتِ مصوب کاوه‌نگار می‌آید، نه از body قابل‌ویرایش DB** (body صرفاً برای رکورد `SmsLog` رندر می‌شود). map توکن‌ها طبق قانون فاصله‌ی کاوه‌نگار: `token` = کد ۵ رقمی (بدون فاصله)، `token10` = اسم سایت (تا ۵ فاصله مجاز). اگر پترن مصوب فقط `%token` داشته باشد، `token10` نادیده گرفته می‌شود (اسم سایت نمایش داده نمی‌شود). اگر `KAVENEGAR_OTP_TEMPLATE` خالی باشد → fallback به ارسال متن‌آزاد `send` با متنِ قالب DB (placeholderهای `{code}`، `{site}`). نیازمند اشتراک advanced کاوه‌نگار.
>
> تگ `welcome`: پیامک خوش‌آمد که هنگام افزودن پزشک/کلینیک توسط نماینده (`POST /api/v1/representation/doctor|clinic`) به‌صورت async به موبایل پزشک/مالک ارسال می‌شود. متن فعلاً ثابت است (نام + `site_name`)، نه از قالب DB.
>
+1
View File
@@ -43,6 +43,7 @@
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-easy-crop": "^6.1.0",
"react-hook-form": "^7.0.0",
"react-hot-toast": "^2.0.0",
"react-leaflet": "^5.0.0",
+13
View File
@@ -20,6 +20,7 @@ class OtpService
private readonly CityRepository $cityRepo,
private readonly int $otpTtl = 1200,
private readonly string $appEnv = 'dev',
private readonly ?string $otpTemplate = null,
) {}
private function key(string $uuid): string
@@ -73,8 +74,20 @@ class OtpService
if ($this->appEnv !== 'dev') {
$site = $this->resolveSiteName($domain);
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]);
if ($this->otpTemplate) {
// Kavenegar VerifyLookup: code has no spaces → token; site may contain spaces → token10.
$this->sms->dispatchAsync(
$mobile,
$message,
templateCode: $this->otpTemplate,
templateVars: ['token' => $code, 'token10' => $site],
tag: SmsLog::TAG_OTP,
);
} else {
$this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP);
}
}
return $uuid;
}
+11
View File
@@ -47,9 +47,20 @@ class KavehNegarProvider implements SmsProviderInterface
{
try {
$params = ['receptor' => $mobile, 'template' => $templateCode];
// Kavenegar token slots: token/token2/token3 reject spaces; token10/token20 allow them.
// If every key is an explicit slot name, honor it (e.g. site with spaces → token10);
// otherwise fall back to positional mapping (token, token2, token3, ...).
$slots = ['token', 'token2', 'token3', 'token10', 'token20'];
if ($vars !== [] && array_keys($vars) !== range(0, count($vars) - 1)
&& array_diff(array_keys($vars), $slots) === []) {
foreach ($vars as $slot => $v) {
$params[$slot] = $v;
}
} else {
foreach (array_values($vars) as $i => $v) {
$params['token' . ($i > 0 ? $i + 1 : '')] = $v;
}
}
$resp = $this->httpClient->request('POST',
self::BASE . '/' . $this->key() . '/verify/lookup.json', [
'body' => http_build_query($params),
+453 -2180
View File
File diff suppressed because it is too large Load Diff