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()); } // کپچای ALTCHA (در dev با ALTCHA_ENABLED=false بی‌اثر) — خطا → ERR_CAPTCHA_001 (422) $this->captcha->assertValid($request); $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'] ?? '')); $mobile = preg_replace('/\D/', '', \App\Shared\Util\PersianText::normalize((string) ($data['mobile'] ?? ''))); if ($mobile !== '' && $mobile !== $user->getMobileNumber()) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'شماره موبایل باید با حساب کاربری شما یکی باشد', 422, 'mobile'); } 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 ); } }