Files
clinicpro/src/Doctor/Controller/DoctorImportController.php
T
hamedandClaude Opus 4.8 af125572c9 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>
2026-07-11 11:39:15 +03:30

87 lines
4.2 KiB
PHP

<?php
namespace App\Doctor\Controller;
use App\Auth\Entity\User;
use App\Doctor\Service\DoctorImportService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* اندپوینت ایمپورت پزشک IRIMC — جدا از AdminApiController تا کاربر سیستمیِ
* کرالر با نقش حداقلی ROLE_IMPORTER (بدون دسترسی به بقیهٔ پنل ادمین) بتواند
* فقط همین عمل را انجام دهد (least privilege). مسیر برای سازگاری با کرالر و
* مستندات، همان مسیر قبلی مانده است.
*/
#[OA\Tag(name: 'Doctors')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class DoctorImportController extends BaseController
{
public function __construct(
private readonly DoctorImportService $importService,
) {}
#[OA\Post(
path: '/api/v1/admin/doctors/import',
summary: 'Import an IRIMC doctor without a mobile number (unclaimed profile)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'medical_system_code'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'medical_system_code', type: 'string'),
new OA\Property(property: 'source', type: 'string', default: 'irimc'),
new OA\Property(property: 'source_ref', type: 'string', nullable: true, description: 'profile_url یا شناسهٔ مبدأ'),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'states', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'cities', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Doctor imported (created)'),
new OA\Response(response: 200, description: 'Doctor already existed (updated or skipped)'),
new OA\Response(response: 403, description: 'Requires ROLE_ADMIN or ROLE_IMPORTER'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/admin/doctors/import', methods: ['POST'])]
public function import(Request $request, #[CurrentUser] User $user): JsonResponse
{
if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_IMPORTER')) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی به این منبع مجاز نیست', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($data['name'] ?? ''));
$code = trim((string) ($data['medical_system_code'] ?? $data['medicalSystemCode'] ?? ''));
if ($name === '') {
return $this->error(ErrorCodes::VALIDATION, 'نام الزامی است', 422, 'name');
}
if ($code === '') {
return $this->error(ErrorCodes::VALIDATION, 'کد نظام پزشکی الزامی است', 422, 'medical_system_code');
}
$result = $this->importService->import($data, $user);
$payload = ['uuid' => $result->doctor->getUuid(), 'created' => $result->created];
if ($result->skipped !== null) {
$payload['skipped'] = $result->skipped;
}
return $this->success($payload, $result->created ? 201 : 200);
}
}