- 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>
9.5 KiB
رفع زوم نقشه در افزودن آدرس + کپچا و موبایل در claim + حذف پروفایل توسط مالک
پروژه
clinicpro (پنل ادمین React + backend)
پرامپت همتا (سایت عمومی):
nobat724_front/.claude/prompt/doctor-map-claim-modal.md— نقشهٔ صفحه پزشک + مودال claim + دکمهٔ حذف. این پرامپت قرارداد API را که سایت مصرف میکند تغییر میدهد.
زمینه
سه موضوع مرتبط با پروفایل پزشک:
- در فرم افزودن آدرس (پنل ادمین)، با انتخاب شهر نقشه باید روی آن شهر زوم کند؛ ولی بار اول کار نمیکند و کاربر مجبور است شهر را دو بار انتخاب کند.
- جریان تصاحب پروفایل (claim) از قبل هست (
DoctorClaimController) ولی طبق سناریوی جدید باید کپچای ALTCHA داشته باشد و شماره موبایل بهصراحت در فرم گرفته و تطبیق داده شود. - پس از 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 |
مستندسازی |
وظیفه ۱ — رفع زوم نقشه هنگام انتخاب شهر (نیازِ دوبار انتخاب)
وضعیت فعلی
// 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); });
}
}}
ریشهها
- نقشهٔ تازهمانتشده اندازهاش ۰ است: وقتی فرم/نقشه تازه باز میشود، Leaflet ابعاد کانتینر را نگرفته و
flyToروی نقشهٔ بدوناندازه بیاثر است؛ انتخاب دومِ شهر (که نقشه دیگر layout شده) کار میکند. بایدmap.invalidateSize()قبل ازflyToصدا زده شود. - geocode خارجی (nominatim) async و rate-limited است: اولین فراخوان ممکن است خالی/۴۰۳ برگردد (کاربر بلافاصله بعد از باز شدن انتخاب میکند) و
setMapFlyTargetاجرا نشود.
راهحل
الف) invalidateSize + recenter مقاوم:
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 استفاده میشود):
// ابتدای DoctorClaimController::claim، پیش از rate limiter/منطق
$this->captcha->assertValid($request); // تزریق CaptchaGuard در constructor
assertValidهنگامALTCHA_ENABLED=falseبیاثر است (dev)، و در prod payload کپچا میخواهد؛ خطای آن بهERR_CAPTCHA_001(۴۲۲) تبدیل میشود (ExceptionSubscriber).
ب) فیلد موبایل صریح — بدنه فیلد mobile بگیرد و با موبایل کاربر لاگینشده تطبیق داده شود (طبق سناریو: «شماره موبایل ثبتشده در حساب کاربری باید به عنوان مالک بررسی شود»):
$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.
وظیفه ۳ — حذف پروفایل توسط مالک
وضعیت فعلی
// 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را بگیر و کنترل دسترسی صریح:
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بیاثر است. - تست:
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 مالک بهروز/تکمیل کن؛ سبز بمانند.