[]]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['title'], properties: [ new OA\Property(property: 'title', type: 'string', description: 'Doctor name (also accepted as "name")'), new OA\Property(property: 'gender', type: 'string', nullable: true), new OA\Property(property: 'medical_system_code', 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: 'activity_time', type: 'integer', nullable: true, description: 'Unix timestamp (seconds) of career start date; basis for years-of-experience'), new OA\Property( property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true, ), new OA\Property( property: 'doctor_services', type: 'array', items: new OA\Items(type: 'integer'), nullable: true, ), ] ) ), responses: [ new OA\Response( response: 201, description: 'Doctor created successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 409, description: 'Doctor profile already exists'), new OA\Response(response: 422, description: 'Validation error'), ] )] #[Route('/api/v1/doctor', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function create(Request $request, #[CurrentUser] User $user): JsonResponse { if ($this->doctorRepo->findByUser($user) !== null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پروفایل دکتر قبلاً ایجاد شده است', 409); } $data = json_decode($request->getContent(), true) ?? []; $name = PersianText::stripDoctorTitle($data['title'] ?? $data['name'] ?? ''); if ($name === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام دکتر الزامی است', 422, 'title'); } $doctor = new Doctor($user, $name); $this->hydrateDoctor($doctor, $data); $this->doctorRepo->save($doctor); // Grant ROLE_DOCTOR to user $roles = $user->getRoles(); if (!in_array('ROLE_DOCTOR', $roles, true)) { $roles[] = 'ROLE_DOCTOR'; $user->setRoles(array_values(array_unique($roles))); $this->userRepo->save($user); } return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))], 201); } #[OA\Get( path: '/api/v1/doctor/{uuid}', summary: 'Get a doctor by UUID', parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response( response: 200, description: 'Doctor detail', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'), ] ) ), new OA\Response(response: 404, description: 'Doctor not found'), ] )] #[Route('/api/v1/doctor/{uuid}', methods: ['GET'])] public function show(string $uuid): JsonResponse { $doctor = $this->doctorRepo->findByUuid($uuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $clinics = $this->clinicRepo->findByDoctor($doctor); $clinicData = array_map(fn(Clinic $c) => [ 'id' => (string) $c->getId(), 'uuid' => $c->getUuid(), 'name' => $c->getName(), 'address' => $c->getAddress(), 'telephone' => $c->getTelephone(), 'city_id' => $c->getCityId(), 'province_id' => $c->getProvinceId(), 'map' => [ 'latitude' => $c->getLatitude() !== null ? (string) $c->getLatitude() : null, 'longitude' => $c->getLongitude() !== null ? (string) $c->getLongitude() : null, ], ], $clinics); $repId = $doctor->getRepresentationId(); $rep = $repId !== null ? $this->representationRepo->find($repId) : null; $representation = $rep !== null ? ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()] : null; $schedules = $this->scheduleRepo->findAllByDoctor($doctor); return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [ 'clinics' => $clinicData, 'representation' => $representation, ])]); } #[Route('/api/v1/clinic/my-doctor/{doctorUuid}', methods: ['GET'])] #[IsGranted('ROLE_CLINIC')] public function showForClinic(string $doctorUuid, #[CurrentUser] User $user): JsonResponse { $clinic = $this->clinicRepo->findByUser($user); if ($clinic === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پزشک یافت نشد', 404); } if (!$clinic->getDoctors()->contains($doctor)) { return $this->error(ErrorCodes::ERR_AUTH_006, 'این پزشک عضو کلینیک شما نیست', 403); } $schedules = $this->scheduleRepo->findAllByDoctor($doctor); return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), ['clinics' => [[ 'id' => (string) $clinic->getId(), 'uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), 'address' => $clinic->getAddress(), 'telephone' => $clinic->getTelephone(), 'city_id' => $clinic->getCityId(), 'province_id' => $clinic->getProvinceId(), 'map' => [ 'latitude' => $clinic->getLatitude() !== null ? (string) $clinic->getLatitude() : null, 'longitude' => $clinic->getLongitude() !== null ? (string) $clinic->getLongitude() : null, ], ]]])]); } #[OA\Get( path: '/api/v1/doctors', summary: 'List doctors with optional filters (paginated)', parameters: [ new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20)), new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'specialty_id', in: 'query', required: false, description: 'Specialty ID', schema: new OA\Schema(type: 'integer')), new OA\Parameter(name: 'city_id', in: 'query', required: false, description: 'City ID — matches the doctor address city or the clinic address city', schema: new OA\Schema(type: 'integer')), new OA\Parameter(name: 'state_id', in: 'query', required: false, description: 'Province ID — matches the doctor address province or the clinic address province', schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response( response: 200, description: 'Paginated doctor list', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), new OA\Property( property: 'meta', properties: [ new OA\Property(property: 'totalRecords', type: 'integer'), new OA\Property(property: 'totalPages', type: 'integer'), new OA\Property(property: 'currentPage', type: 'integer'), ], type: 'object' ), ] ) ), ] )] #[Route('/api/v1/doctors', methods: ['GET'])] public function list(Request $request): JsonResponse { $filters = $this->domainResolver->applyToListFilters($request->query->all()); $result = $this->doctorRepo->findWithFilters($filters); $scheduleMap = []; foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) { $scheduleMap[$schedule->getDoctor()->getId()][] = $schedule; } $locationMap = $this->doctorRepo->findLocationsByDoctors($result['items']); return $this->paginated( array_map( fn(Doctor $d) => $d->toListArray( $scheduleMap[$d->getId()] ?? [], $locationMap[$d->getId()] ?? null ), $result['items'] ), $result['total'], $result['page'], $result['limit'] ); } #[OA\Patch( path: '/api/v1/doctor/{uuid}', summary: 'Update a doctor profile', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: false, content: new OA\JsonContent( properties: [ new OA\Property(property: 'title', type: 'string', nullable: true), new OA\Property(property: 'gender', type: 'string', nullable: true), new OA\Property(property: 'medical_system_code', 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: 'activity_time', type: 'integer', nullable: true, description: 'Unix timestamp (seconds) of career start date; basis for years-of-experience'), new OA\Property( property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true, ), new OA\Property( property: 'doctor_services', type: 'array', items: new OA\Items(type: 'integer'), nullable: true, ), new OA\Property( property: 'social_media', type: 'object', nullable: true, properties: [ new OA\Property(property: 'instagram', type: 'string', nullable: true), new OA\Property(property: 'telegram', type: 'string', nullable: true), new OA\Property(property: 'aparat', type: 'string', nullable: true), new OA\Property(property: 'youtube', type: 'string', nullable: true), new OA\Property(property: 'linkedin', type: 'string', nullable: true), ], ), ] ) ), parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response( response: 200, description: 'Doctor updated successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden'), new OA\Response(response: 404, description: 'Doctor not found'), ] )] #[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUuid($uuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title'])); $this->hydrateDoctor($doctor, $data); $this->doctorRepo->save($doctor); return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]); } #[OA\Delete( path: '/api/v1/doctor/{uuid}', summary: 'Delete a doctor (ROLE_ADMIN only)', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response( response: 200, description: 'Doctor deleted successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', properties: [ new OA\Property(property: 'message', type: 'string'), ]), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'), new OA\Response(response: 404, description: 'Doctor not found'), ] )] #[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUuid($uuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } // ادمین هر پروفایلی را؛ مالک فقط پروفایلِ claimedِ خودش را حذف می‌کند (ضد IDOR) $isOwner = $doctor->getOwnerStatus() === 'claimed' && $doctor->getUser()->getId() === $user->getId(); if (!$user->hasRole('ROLE_ADMIN') && !$isOwner) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'اجازهٔ حذف این پروفایل را ندارید', 403); } if ($this->appointmentRepo->count(['doctor' => $doctor]) > 0) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این پزشک نوبت ثبت‌شده دارد و قابل حذف نیست', 409); } $this->insuranceCleanup->purgeForEntity(TenantInsurance::TYPE_DOCTOR, $doctor->getId()); $this->doctorRepo->remove($doctor); return $this->success(['message' => 'دکتر با موفقیت حذف شد']); } // ── File Upload ─────────────────────────────────────────────────────────── #[OA\Post( path: '/file/upload/clinic_pro/doctor/field_image', summary: 'Upload a doctor profile image (raw binary)', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\MediaType( mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string', format: 'binary') ) ), parameters: [ new OA\Parameter( name: 'Content-Disposition', in: 'header', required: true, description: 'Must include filename, e.g. attachment; filename="photo.jpg"', schema: new OA\Schema(type: 'string') ), ], responses: [ new OA\Response( response: 200, description: 'File uploaded successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', properties: [ new OA\Property(property: 'fid', type: 'integer'), new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), new OA\Property(property: 'url', type: 'string'), new OA\Property(property: 'filename', type: 'string'), new OA\Property(property: 'filemime', type: 'string'), new OA\Property(property: 'filesize', type: 'integer'), ], type: 'object' ), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 422, description: 'Invalid file'), ] )] #[Route('/file/upload/clinic_pro/doctor/field_image', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function uploadImage(Request $request): JsonResponse { $content = $request->getContent(); $disposition = $request->headers->get('Content-Disposition', ''); preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m); $filename = $m[1] ?? 'upload.jpg'; // Write to a tmp file for magic bytes validation $tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true); file_put_contents($tmpPath, $content); try { // validate() checks size, magic bytes, and sanitizes filename $safeFilename = $this->fileValidator->validate($content, $filename); $mime = $this->fileValidator->detectMimeType($tmpPath); $year = date('Y'); $month = date('m'); $dir = $this->projectDir . '/public/uploads/doctors/' . $year . '-' . $month; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $storedName = uniqid('', true) . '_' . $safeFilename; $fullPath = $dir . '/' . $storedName; rename($tmpPath, $fullPath); $filesize = filesize($fullPath); $url = '/uploads/doctors/' . $year . '-' . $month . '/' . $storedName; return $this->success([ 'fid' => time(), 'uuid' => \Symfony\Component\Uid\Uuid::v4()->toRfc4122(), 'url' => $url, 'filename' => $safeFilename, 'filemime' => $mime, 'filesize' => $filesize, ]); } catch (\Throwable $e) { if (file_exists($tmpPath)) unlink($tmpPath); return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422); } } // ── Doctor Addresses ────────────────────────────────────────────────────── #[OA\Post( path: '/api/v1/clinic-pro/doctor-address', summary: 'Create a new doctor address', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: false, content: new OA\JsonContent( properties: [ new OA\Property(property: 'name', type: 'string', nullable: true), new OA\Property(property: 'address', type: 'string', nullable: true), new OA\Property(property: 'telephone', type: 'string', nullable: true), new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true), new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true), ] ) ), responses: [ new OA\Response( response: 201, description: 'Address created successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Address object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden'), new OA\Response(response: 404, description: 'Doctor not found'), new OA\Response(response: 422, description: 'Validation error'), ] )] #[Route('/api/v1/clinic-pro/doctor-address', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function createAddress(Request $request, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUser($user); if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403); } $data = json_decode($request->getContent(), true) ?? []; // Admin can specify doctor_id/doctor_uuid if ($doctor === null && $user->hasRole('ROLE_ADMIN')) { $doctorUuid = $data['doctor_uuid'] ?? null; if (!$doctorUuid) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid الزامی است', 422); } $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } } $address = DoctorAddress::forDoctor($doctor); $this->hydrateAddress($address, $data); $this->addressRepo->save($address); return $this->success(['data' => $address->toArray()], 201); } #[OA\Get( path: '/api/v1/clinic-pro/doctor-address/{id}', summary: 'Get a doctor address by ID', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response( response: 200, description: 'Address detail', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Address object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 404, description: 'Address not found'), ] )] #[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['GET'], requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function showAddress(int $id, #[CurrentUser] User $user): JsonResponse { $address = $this->addressRepo->find($id); if ($address === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); } if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } return $this->success(['data' => $address->toArray()]); } #[OA\Patch( path: '/api/v1/clinic-pro/doctor-address/{id}', summary: 'Update a doctor address', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: false, content: new OA\JsonContent( properties: [ new OA\Property(property: 'name', type: 'string', nullable: true), new OA\Property(property: 'address', type: 'string', nullable: true), new OA\Property(property: 'telephone', type: 'string', nullable: true), new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true), new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true), ] ) ), parameters: [ new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response( response: 200, description: 'Address updated successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Address object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden'), new OA\Response(response: 404, description: 'Address not found'), ] )] #[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['PATCH'], requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function updateAddress(int $id, Request $request, #[CurrentUser] User $user): JsonResponse { $address = $this->addressRepo->find($id); if ($address === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); } if ($address->getType() !== DoctorAddress::TYPE_PERSONAL) { return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک ویرایش می‌شود', 403); } if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; $this->hydrateAddress($address, $data); $this->addressRepo->save($address); return $this->success(['data' => $address->toArray()]); } #[OA\Delete( path: '/api/v1/clinic-pro/doctor-address/{id}', summary: 'Delete a doctor address', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response( response: 200, description: 'Address deleted successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', properties: [ new OA\Property(property: 'message', type: 'string'), ]), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden'), new OA\Response(response: 404, description: 'Address not found'), ] )] #[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['DELETE'], requirements: ['id' => '\d+'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function deleteAddress(int $id, #[CurrentUser] User $user): JsonResponse { $address = $this->addressRepo->find($id); if ($address === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); } if ($address->getType() !== DoctorAddress::TYPE_PERSONAL) { return $this->error(ErrorCodes::ERR_AUTH_006, 'آدرس کلینیک از طریق مدیریت کلینیک حذف می‌شود', 403); } if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $this->addressRepo->remove($address); return $this->success(['message' => 'آدرس با موفقیت حذف شد']); } #[OA\Get( path: '/api/v1/clinic-pro/doctor-addresses/{doctorId}', summary: 'List all addresses for a doctor', parameters: [ new OA\Parameter(name: 'doctorId', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response( response: 200, description: 'Array of address objects', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), ] ) ), new OA\Response(response: 404, description: 'Doctor not found'), ] )] #[Route('/api/v1/clinic-pro/doctor-addresses/{doctorId}', methods: ['GET'], requirements: ['doctorId' => '\d+'])] public function listAddresses(int $doctorId): JsonResponse { $doctor = $this->doctorRepo->find($doctorId); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $personalAddresses = array_map( fn(DoctorAddress $a) => $a->toArray(), $doctor->getAddresses()->toArray() ); $clinicAddresses = []; foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { foreach ($this->addressRepo->findBy(['clinicId' => $clinic->getId()]) as $addr) { $clinicAddresses[] = $addr->toArray($clinic->getName()); } } return $this->success(['data' => array_merge($personalAddresses, $clinicAddresses)]); } // ── Helpers ─────────────────────────────────────────────────────────────── private function hydrateDoctor(Doctor $doctor, array $data): void { if (array_key_exists('gender', $data)) $doctor->setGender($data['gender']); if (array_key_exists('medical_system_code', $data)) $doctor->setMedicalSystemCode($data['medical_system_code']); if (array_key_exists('mobile_number', $data)) $doctor->setMobileNumber($data['mobile_number']); if (array_key_exists('activity_time', $data)) $doctor->setActivityTime((int) $data['activity_time']); if (array_key_exists('degree', $data)) $doctor->setDegree($data['degree']); if (array_key_exists('info', $data)) $doctor->setInfo($data['info']); if (array_key_exists('detail', $data)) $doctor->setInfo($data['detail']); if (array_key_exists('active', $data)) $doctor->setActiveDoctorAppointment((bool) $data['active']); // Images array (from file upload response) if (array_key_exists('image_data', $data)) { $existing = $doctor->getImages() ?? []; $existing[] = $data['image_data']; $doctor->setImages($existing); } if (array_key_exists('images', $data) && is_array($data['images'])) { $doctor->setImages($data['images']); } // Social media links (each key nullable, must be a valid URL when present) if (array_key_exists('social_media', $data) && is_array($data['social_media'])) { $allowedKeys = ['instagram', 'telegram', 'aparat', 'youtube', 'linkedin']; $socialMedia = []; foreach ($allowedKeys as $key) { $value = $data['social_media'][$key] ?? null; $socialMedia[$key] = (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) ? $value : null; } $doctor->setSocialMedia($socialMedia); } // Specialties if (array_key_exists('specialties', $data) && is_array($data['specialties'])) { $doctor->getSpecialties()->clear(); foreach ($data['specialties'] as $id) { $s = $this->specialtyRepo->find((int) $id); if ($s !== null) $doctor->getSpecialties()->add($s); } } // Services (doctor_services / expertise) $servicesKey = array_key_exists('doctor_services', $data) ? 'doctor_services' : (array_key_exists('expertise', $data) ? 'expertise' : null); if ($servicesKey !== null && is_array($data[$servicesKey])) { $doctor->getServices()->clear(); foreach ($data[$servicesKey] as $id) { $ds = $this->serviceRepo->find((int) $id); if ($ds !== null) $doctor->getServices()->add($ds); } } // Provinces (states) if (array_key_exists('states', $data) && is_array($data['states'])) { $doctor->getProvinces()->clear(); foreach ($data['states'] as $id) { $p = $this->provinceRepo->find((int) $id); if ($p !== null) $doctor->getProvinces()->add($p); } } // Cities if (array_key_exists('cities', $data) && is_array($data['cities'])) { $doctor->getCities()->clear(); foreach ($data['cities'] as $id) { $c = $this->cityRepo->find((int) $id); if ($c !== null) $doctor->getCities()->add($c); } } } private function hydrateAddress(DoctorAddress $address, array $data): void { if (array_key_exists('name', $data)) $address->setName($data['name']); if (array_key_exists('address', $data)) $address->setAddress($data['address']); if (array_key_exists('telephone', $data)) $address->setTelephone($data['telephone']); if (isset($data['map']['latitude'])) $address->setLatitude((float) $data['map']['latitude']); if (isset($data['map']['longitude'])) $address->setLongitude((float) $data['map']['longitude']); if (array_key_exists('latitude', $data)) $address->setLatitude((float) $data['latitude']); if (array_key_exists('longitude', $data)) $address->setLongitude((float) $data['longitude']); if (array_key_exists('city_id', $data)) { $city = $data['city_id'] !== null ? $this->cityRepo->find((int) $data['city_id']) : null; $address->setCity($city); } if (array_key_exists('province_id', $data)) { $province = $data['province_id'] !== null ? $this->provinceRepo->find((int) $data['province_id']) : null; $address->setProvince($province); } } }