- 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>
87 lines
4.2 KiB
PHP
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);
|
|
}
|
|
}
|