feat(doctor): claim captcha+mobile, owner profile delete, admin map zoom fix
- DoctorClaimController: ALTCHA CaptchaGuard on /claim (dev no-op via ALTCHA_ENABLED=false); optional `mobile` field must match the logged-in user's number (422 ERR_CONFLICT_001 on mismatch) - DoctorController::delete: now IS_AUTHENTICATED_FULLY — admin (any) or the owner of a claimed profile (IDOR-guarded); FK appointment guard kept - DoctorDetailPage address map: MapController calls map.invalidateSize() before flyTo (fixes needing to pick a city twice on a freshly-mounted map); geocode retries once (nominatim empty/429 on first hit) - tests: mobile mismatch, owner-delete allowed + others 403, unclaimed not deletable by random user - docs: doctor-claim.md (mobile+captcha), doctor.md (delete permission) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
# رفع زوم نقشه در افزودن آدرس + کپچا و موبایل در claim + حذف پروفایل توسط مالک
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (پنل ادمین React + backend)
|
||||
> پرامپت همتا (سایت عمومی): `nobat724_front/.claude/prompt/doctor-map-claim-modal.md` — نقشهٔ صفحه پزشک + مودال claim + دکمهٔ حذف. این پرامپت قرارداد API را که سایت مصرف میکند تغییر میدهد.
|
||||
|
||||
## زمینه
|
||||
|
||||
سه موضوع مرتبط با پروفایل پزشک:
|
||||
1. در فرم افزودن آدرس (پنل ادمین)، با انتخاب شهر نقشه باید روی آن شهر زوم کند؛ ولی بار اول کار نمیکند و کاربر مجبور است شهر را **دو بار** انتخاب کند.
|
||||
2. جریان تصاحب پروفایل (claim) از قبل هست (`DoctorClaimController`) ولی طبق سناریوی جدید باید **کپچای ALTCHA** داشته باشد و **شماره موبایل** بهصراحت در فرم گرفته و تطبیق داده شود.
|
||||
3. پس از claim، **مالک** پروفایل باید بتواند پروفایل خود را حذف کند (الان حذف فقط `ROLE_ADMIN` است).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `assets/admin/pages/DoctorDetailPage.tsx` | فرم آدرس + `MapPicker`/`MapController` (react-leaflet) + geocode شهر |
|
||||
| `src/Doctor/Controller/DoctorClaimController.php` | endpoint `claim` — افزودن کپچا + فیلد mobile |
|
||||
| `src/Doctor/Service/DoctorClaimService.php` | منطق claim |
|
||||
| `src/Shared/Captcha/CaptchaGuard.php` | `assertValid($request)` — الگوی موجود کپچا (در `AuthController`) |
|
||||
| `src/Doctor/Controller/DoctorController.php` | متد `delete` (خط ۳۷۱، الان `#[IsGranted('ROLE_ADMIN')]`) |
|
||||
| `docs/api/doctor.md` + `docs/api/doctor-claim.md` | مستندسازی |
|
||||
|
||||
## وظیفه ۱ — رفع زوم نقشه هنگام انتخاب شهر (نیازِ دوبار انتخاب)
|
||||
|
||||
### وضعیت فعلی
|
||||
|
||||
```tsx
|
||||
// DoctorDetailPage.tsx:468 — recenter فقط با flyTo روی تغییر flyTarget
|
||||
function MapController({ flyTarget }: { flyTarget: [number, number] | null }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (flyTarget) map.flyTo(flyTarget, 12, { duration: 1.2 });
|
||||
}, [flyTarget, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// :935 — انتخاب شهر → geocode خارجی → setMapFlyTarget
|
||||
onChange={(val, label) => {
|
||||
setValue('city_id', val);
|
||||
if (label) {
|
||||
geocodeCityInIran(label).then(coords => { if (coords) setMapFlyTarget(coords); });
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
### ریشهها
|
||||
|
||||
1. **نقشهٔ تازهمانتشده اندازهاش ۰ است:** وقتی فرم/نقشه تازه باز میشود، Leaflet ابعاد کانتینر را نگرفته و `flyTo` روی نقشهٔ بدوناندازه بیاثر است؛ انتخاب دومِ شهر (که نقشه دیگر layout شده) کار میکند. باید `map.invalidateSize()` قبل از `flyTo` صدا زده شود.
|
||||
2. **geocode خارجی (nominatim) async و rate-limited است:** اولین فراخوان ممکن است خالی/۴۰۳ برگردد (کاربر بلافاصله بعد از باز شدن انتخاب میکند) و `setMapFlyTarget` اجرا نشود.
|
||||
|
||||
### راهحل
|
||||
|
||||
**الف) `invalidateSize` + recenter مقاوم:**
|
||||
|
||||
```tsx
|
||||
function MapController({ flyTarget }: { flyTarget: [number, number] | null }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.invalidateSize(); // ابعاد را پس از mount/تغییر layout بهروز کن
|
||||
if (flyTarget) map.flyTo(flyTarget, 12, { duration: 1.2 });
|
||||
}, [flyTarget, map]);
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
اگر نقشه داخل بخشی است که با باز/بسته شدن mount/unmount میشود، یک `invalidateSize` هنگام mount هم لازم است (effect بالا با `map` در deps این را پوشش میدهد).
|
||||
|
||||
**ب) geocode مقاوم — ترجیحاً از مختصات خودِ شهر بهجای سرویس خارجی:**
|
||||
|
||||
- اول بررسی کن آیا آبجکت شهر در `cities` (یا endpoint `/api/v1/cities`) مختصات دارد؛ اگر دارد، مستقیم از همان `flyTarget` را بساز و از nominatim صرفنظر کن (سریع، بدون rate-limit، بدون async ناموفق).
|
||||
- اگر مختصات در دیتا نیست، `geocodeCityInIran` را نگهدار اما با retry ساده (یک تلاش مجدد بعد از ~۱ ثانیه در صورت پاسخ خالی) و بدون بلاککردن UI.
|
||||
|
||||
> **edge:** اگر کاربر پیش از resolve شدن geocode شهر دیگری انتخاب کند، فقط آخرین انتخاب باید اعمال شود (نگهداشتن یک request id/ابطال نتیجهٔ قدیمی).
|
||||
|
||||
## وظیفه ۲ — کپچا و فیلد موبایل در claim
|
||||
|
||||
### وضعیت فعلی
|
||||
|
||||
`DoctorClaimController::claim` پشت `IS_AUTHENTICATED_FULLY` است و کپچا ندارد؛ موبایل را از کاربر لاگینشده میگیرد (`$user->getMobileNumber()`)، فیلد جدا در بدنه ندارد.
|
||||
|
||||
### راهحل
|
||||
|
||||
**الف) کپچای ALTCHA** — الگوی موجود `CaptchaGuard::assertValid($request)` (همان که در `AuthController::sendCode` استفاده میشود):
|
||||
|
||||
```php
|
||||
// ابتدای DoctorClaimController::claim، پیش از rate limiter/منطق
|
||||
$this->captcha->assertValid($request); // تزریق CaptchaGuard در constructor
|
||||
```
|
||||
|
||||
- `assertValid` هنگام `ALTCHA_ENABLED=false` بیاثر است (dev)، و در prod payload کپچا میخواهد؛ خطای آن به `ERR_CAPTCHA_001` (۴۲۲) تبدیل میشود (ExceptionSubscriber).
|
||||
|
||||
**ب) فیلد موبایل صریح** — بدنه فیلد `mobile` بگیرد و با موبایل کاربر لاگینشده تطبیق داده شود (طبق سناریو: «شماره موبایل ثبتشده در حساب کاربری باید به عنوان مالک بررسی شود»):
|
||||
|
||||
```php
|
||||
$mobile = \App\Shared\Util\PersianText::normalize((string) ($data['mobile'] ?? ''));
|
||||
$mobile = preg_replace('/\D/', '', $mobile);
|
||||
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
|
||||
}
|
||||
if ($mobile !== $user->getMobileNumber()) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'شماره موبایل باید با حساب کاربری شما یکی باشد', 422, 'mobile');
|
||||
}
|
||||
```
|
||||
|
||||
- `DoctorClaimService::claim` بدون تغییر میماند (همان موبایل کاربر برای شاهکار استفاده میشود).
|
||||
- سند `docs/api/doctor-claim.md`: افزودن فیلد `mobile` به بدنه + ذکر کپچای ALTCHA و کد `ERR_CAPTCHA_001`.
|
||||
|
||||
## وظیفه ۳ — حذف پروفایل توسط مالک
|
||||
|
||||
### وضعیت فعلی
|
||||
|
||||
```php
|
||||
// DoctorController.php:369
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function delete(string $uuid): JsonResponse { ... }
|
||||
```
|
||||
|
||||
فقط ادمین حذف میکند؛ مالک پزشک نمیتواند پروفایل خود را حذف کند.
|
||||
|
||||
### راهحل
|
||||
|
||||
مالک (`claimed` و `doctor.getUser()->getId() === user`) هم اجازهٔ حذف بگیرد:
|
||||
|
||||
- `#[IsGranted('ROLE_ADMIN')]` را از متد بردار و به `#[IsGranted('IS_AUTHENTICATED_FULLY')]` تغییر بده؛ داخل متد `#[CurrentUser] User $user` را بگیر و کنترل دسترسی صریح:
|
||||
|
||||
```php
|
||||
public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
$isAdmin = $user->hasRole('ROLE_ADMIN');
|
||||
$isOwner = $doctor->getOwnerStatus() === 'claimed' && $doctor->getUser()->getId() === $user->getId();
|
||||
if (!$isAdmin && !$isOwner) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'اجازهٔ حذف این پروفایل را ندارید', 403);
|
||||
}
|
||||
// ... گاردِ FK موجود (نوبت ثبتشده → 409) و remove فعلی بدون تغییر ...
|
||||
}
|
||||
```
|
||||
|
||||
- گارد FK موجود (پزشکِ دارای نوبت → ۴۰۹ `ERR_CONFLICT_001`) حفظ شود.
|
||||
- امنیت: IDOR — کاربرِ لاگینشده فقط پروفایلِ **claimed متعلق به خودش** یا (اگر ادمین) هر پروفایلی را حذف کند؛ پروفایلِ `unclaimed` توسط کاربر عادی حذف نشود.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- بعد از تغییر `delete`/`claim`، `docs/api/doctor.md` و `docs/api/doctor-claim.md` را بهروز کن (قانون پروژه).
|
||||
- نقشهٔ react-leaflet فقط admin frontend است؛ تغییر backend ندارد.
|
||||
- کپچا: مسیر public سایت (`nobat724`) هم باید payload ALTCHA بفرستد (پرامپت همتا)؛ در dev با `ALTCHA_ENABLED=false` بیاثر است.
|
||||
- تست:
|
||||
```bash
|
||||
ddev exec php -l src/Doctor/Controller/DoctorClaimController.php
|
||||
ddev exec php bin/console cache:clear
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head
|
||||
ddev exec yarn dev
|
||||
# claim: با mobile نامطابق → 422؛ delete توسط مالک claimed → 200؛ توسط کاربر دیگر → 403
|
||||
ddev exec php bin/phpunit tests/Doctor
|
||||
```
|
||||
- تستهای موجود `DoctorClaimTest`/`DoctorImportTest` را با فیلد `mobile` و مسیر delete مالک بهروز/تکمیل کن؛ سبز بمانند.
|
||||
Reference in New Issue
Block a user