Files
clinicpro/.claude/prompt/doctor-map-claim-captcha-delete.md
hamedandClaude Opus 4.8 2f0131171d 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>
2026-07-11 14:57:41 +03:30

9.5 KiB

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

وظیفه ۱ — رفع زوم نقشه هنگام انتخاب شهر (نیازِ دو‌بار انتخاب)

وضعیت فعلی

// 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 مقاوم:

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 مالک به‌روز/تکمیل کن؛ سبز بمانند.