feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key

- Extract import logic from AdminApiController into DoctorImportService
  (thin DoctorImportController keeps the same route/contract)
- Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command
  app:doctors:backfill-surrogate-role) enabling safe deletion after claim
- DB-level UNIQUE (source, medical_system_code) + concurrent-import retry
- Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks
  via existing ApiIrService, Persian name normalization (PersianText),
  pessimistic-lock race protection, DoctorClaimRequest audit table
  (national code hashed, mobile masked), doctor_claim rate limiter,
  public claim-info endpoint, welcome SMS
- Admin support tools: manual transfer endpoint + paginated doctor-claims
  audit list + owner_status filter/fields in admin doctors list
- Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped),
  import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER
- Headless crawler login: X-Service-Token header bypasses captcha only
  (rate limit + password checks intact; empty env = no bypass)
- docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md
- tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-11 11:39:15 +03:30
co-authored by Claude Opus 4.8
parent 83c872bb78
commit af125572c9
29 changed files with 1944 additions and 303 deletions
+6
View File
@@ -29,3 +29,9 @@ framework:
policy: 'sliding_window'
limit: 5
interval: '60 minutes'
# Doctor profile claim: max 5 attempts per hour per (user, doctor) — ضد brute-force هویت
doctor_claim:
policy: 'sliding_window'
limit: 5
interval: '60 minutes'
+1 -1
View File
@@ -33,7 +33,7 @@ security:
provider: api_doc_provider
public_endpoints:
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$)
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/appointment-settings/month-availability/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$)
stateless: true
security: false
+1
View File
@@ -76,6 +76,7 @@ services:
App\Auth\Security\PasswordAuthenticator:
arguments:
$refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%'
$crawlerServiceToken: '%env(default::CRAWLER_SERVICE_TOKEN)%'
$loginLimiter: '@limiter.login'
App\Auth\Controller\AuthController: