# رفع زوم نقشه در افزودن آدرس + کپچا و موبایل در 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 مالک به‌روز/تکمیل کن؛ سبز بمانند.