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:
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Doctor\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Doctor\Service\DoctorClaimService;
|
||||
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\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Doctors')]
|
||||
class DoctorClaimController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorClaimService $claimService,
|
||||
private readonly RateLimiterFactory $doctorClaimLimiter,
|
||||
private readonly \App\Doctor\Repository\DoctorClaimRequestRepository $claimRepo,
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/doctor/{uuid}/claim-info',
|
||||
summary: 'Whether this doctor profile can be claimed (public, renders the claim button)',
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: '{ claimable, owner_status }'),
|
||||
new OA\Response(response: 404, description: 'Doctor not found'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/doctor/{uuid}/claim-info', methods: ['GET'])]
|
||||
public function claimInfo(string $uuid): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'claimable' => $doctor->getOwnerStatus() === 'unclaimed',
|
||||
'owner_status' => $doctor->getOwnerStatus(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/doctor/{uuid}/claim',
|
||||
summary: 'Claim an unclaimed (IRIMC-imported) doctor profile after identity verification',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['national_code', 'birth_date', 'first_name', 'last_name'],
|
||||
properties: [
|
||||
new OA\Property(property: 'national_code', type: 'string', example: '0010007700'),
|
||||
new OA\Property(property: 'birth_date', type: 'string', description: 'شمسی Y/m/d', example: '1371/1/1'),
|
||||
new OA\Property(property: 'first_name', type: 'string'),
|
||||
new OA\Property(property: 'last_name', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Claimed'),
|
||||
new OA\Response(response: 409, description: 'Not claimable / user already owns a doctor'),
|
||||
new OA\Response(response: 422, description: 'Validation or identity mismatch'),
|
||||
new OA\Response(response: 429, description: 'Rate limited'),
|
||||
new OA\Response(response: 502, description: 'Identity provider unavailable'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/doctor/{uuid}/claim', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function claim(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$limiter = $this->doctorClaimLimiter->create('claim_' . $user->getId() . '_' . $uuid);
|
||||
$limit = $limiter->consume();
|
||||
if (!$limit->isAccepted()) {
|
||||
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$nationalCode = preg_replace('/\D/', '', \App\Shared\Util\PersianText::normalize((string) ($data['national_code'] ?? '')));
|
||||
$birthDate = trim(\App\Shared\Util\PersianText::normalize((string) ($data['birth_date'] ?? '')));
|
||||
$firstName = trim((string) ($data['first_name'] ?? ''));
|
||||
$lastName = trim((string) ($data['last_name'] ?? ''));
|
||||
|
||||
if (!preg_match('/^\d{10}$/', $nationalCode)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code');
|
||||
}
|
||||
if (!preg_match('~^1[34]\d{2}/\d{1,2}/\d{1,2}$~', $birthDate)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ تولد نامعتبر است (مثال: 1371/1/1)', 422, 'birth_date');
|
||||
}
|
||||
if ($firstName === '' || $lastName === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام و نام خانوادگی الزامی است', 422);
|
||||
}
|
||||
|
||||
$claim = $this->claimService->claim($doctor, $user, $nationalCode, $birthDate, $firstName, $lastName);
|
||||
|
||||
return $this->success([
|
||||
'status' => 'claimed',
|
||||
'claim' => ['uuid' => $claim->getUuid()],
|
||||
'doctor' => ['uuid' => $doctor->getUuid(), 'name' => $doctor->getName()],
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/v1/admin/doctors/{uuid}/transfer',
|
||||
summary: 'Manually transfer an unclaimed doctor profile to a real user (admin support tool)',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['mobile'],
|
||||
properties: [new OA\Property(property: 'mobile', type: 'string', example: '09121234567')]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Transferred'),
|
||||
new OA\Response(response: 404, description: 'Doctor not found'),
|
||||
new OA\Response(response: 409, description: 'Already claimed / target user owns another doctor'),
|
||||
new OA\Response(response: 422, description: 'Invalid mobile'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function transfer(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim((string) ($data['mobile'] ?? ''));
|
||||
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile');
|
||||
}
|
||||
|
||||
$claim = $this->claimService->transferByAdmin($doctor, $mobile);
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $doctor->getUuid(),
|
||||
'owner_status' => $doctor->getOwnerStatus(),
|
||||
'user_mobile' => $mobile,
|
||||
'claim' => ['uuid' => $claim->getUuid()],
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/doctor-claims',
|
||||
summary: 'Paginated audit list of doctor profile claim requests',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'status', in: 'query', schema: new OA\Schema(type: 'string', enum: ['pending', 'completed', 'failed'])),
|
||||
new OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer', default: 20)),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'Paginated claim requests')]
|
||||
)]
|
||||
#[Route('/api/v1/admin/doctor-claims', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function claimsList(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
$status = trim((string) $request->query->get('status', ''));
|
||||
|
||||
$qb = $this->claimRepo->createQueryBuilder('c')
|
||||
->orderBy('c.createdAt', 'DESC');
|
||||
|
||||
if (in_array($status, [\App\Doctor\Entity\DoctorClaimRequest::STATUS_PENDING, \App\Doctor\Entity\DoctorClaimRequest::STATUS_COMPLETED, \App\Doctor\Entity\DoctorClaimRequest::STATUS_FAILED], true)) {
|
||||
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
|
||||
$total = (int) (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(\App\Doctor\Entity\DoctorClaimRequest $c) => $c->toArray(), $items),
|
||||
$total,
|
||||
$page,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user