- 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>
157 lines
7.7 KiB
Markdown
157 lines
7.7 KiB
Markdown
# Doctor Profile Claim API (تصاحب پروفایل پزشک ایمپورتشده)
|
|
|
|
> **Controller:** `App\Doctor\Controller\DoctorClaimController` — منطق در `App\Doctor\Service\DoctorClaimService`
|
|
> **مصرفکننده:** سایت عمومی Nobat724 (همهٔ دامنهها) + پنل ادمین
|
|
|
|
پزشکِ ایمپورتشده از نظام پزشکی (`owner_status = unclaimed`) توسط پزشک واقعی تصاحب میشود.
|
|
احراز هویت سمت سرور با **API.ir** انجام میشود (شاهکار: تطبیق موبایل↔کدملی؛ PersonInfo: تطبیق
|
|
کدملی+تاریخ تولد و نام). هیچ درخواستی از فرانت به API.ir نمیرود و توکن API.ir هرگز به کلاینت
|
|
نمیرسد. claim پس از تطبیق موفق **خودکار** نهایی میشود (بدون approve ادمین — تصمیم مستند در
|
|
`.claude/prompt/irimc-import-complete.md` §۲.۲).
|
|
|
|
## چرخهٔ وضعیت
|
|
|
|
```
|
|
unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق──▶ claimed
|
|
▲ │شکست تطبیق/خطای استعلام
|
|
└───────────────────────────────────┘ (برگشت، قابل تلاش مجدد)
|
|
```
|
|
|
|
هر تلاش یک رکورد ممیزی در `doctor_claim_requests` میسازد — کد ملی فقط **hash sha256** و
|
|
موبایل فقط **mask شده** ذخیره میشود؛ هیچ دادهٔ هویتی خام در DB یا لاگ نمیماند.
|
|
|
|
---
|
|
|
|
## GET `/api/v1/doctor/{uuid}/claim-info`
|
|
|
|
**Permission:** عمومی (بدون JWT) — فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟»
|
|
|
|
### Response `200`
|
|
```json
|
|
{ "success": true, "data": { "claimable": true, "owner_status": "unclaimed" } }
|
|
```
|
|
|
|
| کد | حالت |
|
|
|---|---|
|
|
| `404` | پزشک یافت نشد |
|
|
|
|
---
|
|
|
|
## POST `/api/v1/doctor/{uuid}/claim`
|
|
|
|
**Permission:** `IS_AUTHENTICATED_FULLY` — کاربر با OTP لاگین شده (موبایلش تأییدشده است)
|
|
**Rate limit:** limiter `doctor_claim` — ۵ تلاش در ساعت بهازای هر (کاربر، پزشک)
|
|
**Captcha:** ALTCHA — بدنه باید payload کپچا بفرستد (`CaptchaGuard::assertValid`)؛ در dev با `ALTCHA_ENABLED=false` بیاثر است، در prod اجباری. خطا → `ERR_CAPTCHA_001` (۴۲۲).
|
|
|
|
### Request
|
|
```json
|
|
{
|
|
"national_code": "0010007700",
|
|
"birth_date": "1371/1/1",
|
|
"first_name": "فرخنده",
|
|
"last_name": "حسینی",
|
|
"mobile": "09121234567",
|
|
"altcha": "<payload کپچا>"
|
|
}
|
|
```
|
|
|
|
| فیلد | الزامی | قاعده |
|
|
|---|:---:|---|
|
|
| `national_code` | ✅ | ۱۰ رقم (ارقام فارسی پذیرفته و نرمال میشوند) |
|
|
| `birth_date` | ✅ | شمسی `Y/m/d` |
|
|
| `first_name` / `last_name` | ✅ | با هویت ثبت احوال و نام پروفایل تطبیق داده میشود (نرمالسازی ي/ی، ك/ک، نیمفاصله — `PersianText`) |
|
|
| `mobile` | ❌ | اگر داده شود، باید با موبایل حساب کاربری یکی باشد وگرنه `422 ERR_CONFLICT_001`؛ اگر خالی باشد از موبایل کاربر لاگینشده استفاده میشود |
|
|
| `altcha` | prod | payload کپچای ALTCHA |
|
|
|
|
### مراحل سرور (اتمیک/ضد race)
|
|
|
|
1. قفل `PESSIMISTIC_WRITE` روی ردیف پزشک → اگر `unclaimed` نبود `409`؛ اگر کاربر از قبل پزشکی دارد `409`؛ اگر کد ملی متعلق به کاربر دیگری است `409` — سپس `pending_transfer` + رکورد ممیزی (تراکنش کوتاه، بدون فراخوان خارجی داخل قفل).
|
|
2. شاهکار (`ApiIrService::shahkarMatch`) — تطبیق موبایل کاربر با کد ملی. اگر API.ir پیکربندی نشده باشد، این گام skip و مبنا موبایلِ OTP-تأییدشده است.
|
|
3. `PersonInfo` — تطبیق کدملی+تاریخ تولد؛ `alive=false` → رد.
|
|
4. تطبیق نام: ورودی کاربر ↔ هویت تأییدشده ↔ نام پروفایل (بدون پیشوند «دکتر»).
|
|
5. نهاییسازی اتمیک: `user_id` → کاربر واقعی، `ROLE_DOCTOR`، `national_code_verified=true`، `owner_status=claimed`، حذف امنِ کاربر جانشین (فقط با `ROLE_UNCLAIMED_DOCTOR` و بدون پزشک دیگر).
|
|
6. پیامک خوشآمد (تمپلیت `welcome`، async).
|
|
|
|
> شکست در گامهای ۲-۴ پروفایل را به `unclaimed` برمیگرداند تا پزشک واقعی بتواند دوباره تلاش کند.
|
|
> نوبتدهی (`active_doctor_appointment`) خاموش میماند تا مالک جدید برنامهٔ کاری تعریف کند.
|
|
|
|
### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"status": "claimed",
|
|
"claim": { "uuid": "…" },
|
|
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" }
|
|
}
|
|
}
|
|
```
|
|
|
|
### Errors
|
|
| کد | HTTP | حالت |
|
|
|---|---|---|
|
|
| `ERR_AUTH_001` | 401 | بدون لاگین |
|
|
| `ERR_NOT_FOUND_001` | 404 | پزشک یافت نشد |
|
|
| `ERR_CONFLICT_001` | 409 | پروفایل قابل تصاحب نیست / کاربر پزشک دیگری دارد / درخواست همزمان دیگری در جریان است |
|
|
| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای کاربر دیگری ثبت شده است |
|
|
| `ERR_VALIDATION_001/002` | 422 | کد ملی/تاریخ/نام نامعتبر |
|
|
| `ERR_IDENTITY_001` | 422 | عدم تطبیق هویت (شاهکار/ثبت احوال/نام) — پیام عمومی، ضد enumeration |
|
|
| `ERR_RATE_LIMIT_001` | 429 | عبور از سقف تلاش |
|
|
| `ERR_EXTERNAL_001` | 502 | خطا/تایماوت API.ir |
|
|
| `ERR_EXTERNAL_002` | 503 | API.ir پیکربندی نشده (env) |
|
|
|
|
---
|
|
|
|
## POST `/api/v1/admin/doctors/{uuid}/transfer`
|
|
|
|
انتقال دستی (ابزار پشتیبانی ادمین) — مستند کامل در `docs/api/doctor-import.md`.
|
|
**Permission:** `ROLE_ADMIN`. بدنه `{ "mobile": "09…" }`. همان نهاییسازی claim را اجرا میکند
|
|
و رکورد ممیزی با `verification_method="admin_manual"` میسازد.
|
|
|
|
---
|
|
|
|
## GET `/api/v1/admin/doctor-claims`
|
|
|
|
**Permission:** `ROLE_ADMIN` — لیست ممیزی درخواستهای claim برای پشتیبانی عملیاتی.
|
|
|
|
| Query | پیشفرض | توضیح |
|
|
|---|---|---|
|
|
| `status` | همه | `pending` \| `completed` \| `failed` |
|
|
| `page` / `limit` | 1 / 20 (سقف 50) | صفحهبندی استاندارد |
|
|
|
|
### Response `200` (paginated)
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": [
|
|
{
|
|
"uuid": "…",
|
|
"status": "completed",
|
|
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" },
|
|
"mobile_masked": "0912***4567",
|
|
"verification_method": "apiir_personinfo+shahkar",
|
|
"failure_reason": null,
|
|
"created_at": 1783750000,
|
|
"completed_at": 1783750040
|
|
}
|
|
],
|
|
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
|
}
|
|
```
|
|
|
|
`failure_reason` فارسی و انسانیخوان است تا ادمین بدون خواندن لاگ سرور علت شکست را ببیند.
|
|
|
|
---
|
|
|
|
## env های مرتبط
|
|
|
|
| متغیر | نقش |
|
|
|---|---|
|
|
| `APIIR_*` (baseUrl/token موجود `ApiIrService`) | استعلام شاهکار و PersonInfo |
|
|
| `CRAWLER_SERVICE_TOKEN` | فقط برای لاگین سرویسی کرالر (`doctor-import.md`) — ربطی به claim ندارد |
|
|
|
|
## تستها
|
|
|
|
`tests/Doctor/DoctorClaimTest.php` (۱۱ سناریو، API.ir همیشه mock — هیچ تستی به سرویس واقعی
|
|
درخواست نمیزند) و `tests/Shared/PersianTextTest.php` (نرمالسازی نام).
|