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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,7 +74,19 @@ class OtpService
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
@@ -47,8 +47,19 @@ class KavehNegarProvider implements SmsProviderInterface
|
||||
{
|
||||
try {
|
||||
$params = ['receptor' => $mobile, 'template' => $templateCode];
|
||||
foreach (array_values($vars) as $i => $v) {
|
||||
$params['token' . ($i > 0 ? $i + 1 : '')] = $v;
|
||||
// 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', [
|
||||
|
||||
Reference in New Issue
Block a user