[]]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( properties: [ new OA\Property(property: 'name', type: 'string'), new OA\Property(property: 'info', 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: 'working_days', type: 'array', items: new OA\Items(type: 'string'), nullable: true), new OA\Property(property: '24_7', type: 'boolean', nullable: true), new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true), new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true), new OA\Property(property: 'state', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), new OA\Property(property: 'city', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), new OA\Property(property: 'image_clinic', type: 'array', items: new OA\Items(type: 'string'), nullable: true), new OA\Property(property: 'clinic_logo', type: 'string', nullable: true), new OA\Property(property: 'doctors', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), 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: 'insurance', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), ] ) ), responses: [ new OA\Response( response: 201, description: 'Clinic created', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), ] )] #[Route('/api/v1/clinic', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function create(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $clinic = new Clinic($user); $this->hydrateClinic($clinic, $data); $this->clinicRepo->save($clinic); // Grant ROLE_CLINIC to user $roles = $user->getRoles(); if (!in_array('ROLE_CLINIC', $roles, true)) { $roles[] = 'ROLE_CLINIC'; $user->setRoles(array_values(array_unique($roles))); $this->userRepo->save($user); } return $this->success(['data' => $clinic->toDetailArray()], 201); } #[OA\Get( path: '/api/v1/clinic/{uuid}', summary: 'Get clinic details by UUID', parameters: [ new OA\Parameter( name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), ], responses: [ new OA\Response( response: 200, description: 'Clinic details', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object'), ] ) ), new OA\Response(response: 404, description: 'Clinic not found'), ] )] #[Route('/api/v1/clinic/{uuid}', methods: ['GET'])] public function show(string $uuid): JsonResponse { $clinic = $this->clinicRepo->findByUuid($uuid); if ($clinic === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } [$stateData, $cityData] = $this->loadLocationData($clinic); return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]); } #[OA\Patch( path: '/api/v1/clinic/{uuid}', summary: 'Update clinic details', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( properties: [ new OA\Property(property: 'name', type: 'string', nullable: true), new OA\Property(property: 'info', 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: 'working_days', type: 'array', items: new OA\Items(type: 'string'), nullable: true), new OA\Property(property: '24_7', type: 'boolean', nullable: true), new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true), new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true), new OA\Property(property: 'state', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), new OA\Property(property: 'city', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), new OA\Property(property: 'image_clinic', type: 'array', items: new OA\Items(type: 'string'), nullable: true), new OA\Property(property: 'clinic_logo', type: 'string', nullable: true), new OA\Property(property: 'doctors', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), 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: 'insurance', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), ] ) ), parameters: [ new OA\Parameter( name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), ], responses: [ new OA\Response( response: 200, description: 'Clinic updated', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object'), ] ) ), new OA\Response(response: 401, description: 'Unauthorized'), new OA\Response(response: 403, description: 'Forbidden'), new OA\Response(response: 404, description: 'Clinic not found'), ] )] #[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $clinic = $this->clinicRepo->findByUuid($uuid); if ($clinic === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; $this->hydrateClinic($clinic, $data); $this->clinicRepo->save($clinic); [$stateData, $cityData] = $this->loadLocationData($clinic); return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]); } #[OA\Get( path: '/api/v1/clinics', summary: 'List clinics 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: 'name', in: 'query', required: false, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'city', in: 'query', required: false, schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response( response: 200, description: 'Paginated list of clinics', 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/clinics', methods: ['GET'])] public function list(Request $request): JsonResponse { $filters = $request->query->all(); $result = $this->clinicRepo->findWithFilters($filters); return $this->paginated( array_map(fn(Clinic $c) => $c->toListArray(), $result['items']), $result['total'], $result['page'], $result['limit'] ); } #[OA\Get( path: '/api/v1/clinic/doctor-list/{clinicUuid}', summary: 'Get list of doctors for a clinic', parameters: [ new OA\Parameter( name: 'clinicUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), ], responses: [ new OA\Response( response: 200, description: 'List of doctors', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', properties: [ new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), ], type: 'object' ), ] ) ), new OA\Response(response: 404, description: 'Clinic not found'), ] )] #[Route('/api/v1/clinic/doctor-list/{clinicUuid}', methods: ['GET'])] public function doctorList(string $clinicUuid): JsonResponse { $clinic = $this->clinicRepo->findByUuid($clinicUuid); if ($clinic === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } $doctors = array_map( fn(Doctor $d) => $d->toListArray(), $clinic->getDoctors()->toArray() ); return $this->success(['data' => $doctors]); } #[OA\Post( path: '/file/upload/clinic_pro/clinic/field_image_clinic', summary: 'Upload a clinic gallery image', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\MediaType( mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string', format: 'binary') ) ), responses: [ new OA\Response( response: 200, description: 'Image uploaded', 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/clinic/field_image_clinic', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function uploadImage(Request $request): JsonResponse { return $this->handleFileUpload($request, 'clinics/gallery'); } #[OA\Post( path: '/file/upload/clinic_pro/clinic/field_clinic_logo', summary: 'Upload a clinic logo image', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\MediaType( mediaType: 'application/octet-stream', schema: new OA\Schema(type: 'string', format: 'binary') ) ), responses: [ new OA\Response( response: 200, description: 'Logo uploaded', 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/clinic/field_clinic_logo', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function uploadLogo(Request $request): JsonResponse { return $this->handleFileUpload($request, 'clinics/logo'); } // ── Helpers ─────────────────────────────────────────────────────────────── private function hydrateClinic(Clinic $clinic, array $data): void { if (array_key_exists('name', $data)) $clinic->setName($data['name']); if (array_key_exists('info', $data)) $clinic->setInfo($data['info']); if (array_key_exists('address', $data)) $clinic->setAddress($data['address']); if (array_key_exists('telephone', $data)) $clinic->setTelephone($data['telephone']); if (array_key_exists('working_days', $data)) $clinic->setWorkingDays($data['working_days']); if (array_key_exists('24_7', $data)) $clinic->setIs247((bool) $data['24_7']); if (array_key_exists('latitude', $data)) $clinic->setLatitude((float) $data['latitude']); if (array_key_exists('longitude', $data)) $clinic->setLongitude((float) $data['longitude']); // Location if (!empty($data['state']) && is_array($data['state'])) { $clinic->setProvinceId((int) $data['state'][0]); } if (!empty($data['city']) && is_array($data['city'])) { $clinic->setCityId((int) $data['city'][0]); } // Images stored as JSON (from upload response) if (array_key_exists('image_clinic', $data) && is_array($data['image_clinic'])) { $clinic->setImagesClinic($data['image_clinic']); } if (array_key_exists('clinic_logo', $data)) { $logo = $data['clinic_logo']; // Accept either a plain URL string or an upload-response object with 'url' key if (is_array($logo) && isset($logo['url'])) { $logo = $logo['url']; } elseif (is_array($logo) && !empty($logo[0]['url'])) { $logo = $logo[0]['url']; } $clinic->setClinicLogo(is_string($logo) ? $logo : null); } // ManyToMany: doctors if (array_key_exists('doctors', $data) && is_array($data['doctors'])) { $clinic->getDoctors()->clear(); foreach ($data['doctors'] as $doctorId) { $doctor = $this->doctorRepo->find((int) $doctorId); if ($doctor !== null) { $clinic->getDoctors()->add($doctor); } } } // ManyToMany: specialties if (array_key_exists('specialties', $data) && is_array($data['specialties'])) { $clinic->getSpecialties()->clear(); foreach ($data['specialties'] as $id) { $specialty = $this->specialtyRepo->find((int) $id); if ($specialty !== null) { $clinic->getSpecialties()->add($specialty); } } } // ManyToMany: services (doctor_services) if (array_key_exists('doctor_services', $data) && is_array($data['doctor_services'])) { $clinic->getServices()->clear(); foreach ($data['doctor_services'] as $id) { $service = $this->serviceRepo->find((int) $id); if ($service !== null) { $clinic->getServices()->add($service); } } } // ManyToMany: insurances if (array_key_exists('insurance', $data) && is_array($data['insurance'])) { $clinic->getInsurances()->clear(); foreach ($data['insurance'] as $id) { $insurance = $this->insuranceRepo->find((int) $id); if ($insurance !== null) { $clinic->getInsurances()->add($insurance); } } } } private function loadLocationData(Clinic $clinic): array { $provinceData = []; $cityData = []; if ($clinic->getProvinceId() !== null) { $province = $this->provinceRepo->find($clinic->getProvinceId()); if ($province !== null) { $provinceData = ['uuid' => $province->getUuid(), 'id' => (string) $province->getId(), 'name' => $province->getName()]; } } if ($clinic->getCityId() !== null) { $city = $this->cityRepo->find($clinic->getCityId()); if ($city !== null) { $cityData = [ 'uuid' => $city->getUuid(), 'id' => (string) $city->getId(), 'name' => $city->getName(), 'parent' => $city->getProvinceId() !== null ? (string) $city->getProvinceId() : null, ]; } } return [$provinceData, $cityData]; } private function handleFileUpload(Request $request, string $subDir): JsonResponse { $content = $request->getContent(); $disposition = $request->headers->get('Content-Disposition', ''); preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m); $filename = $m[1] ?? 'upload.jpg'; $tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true); file_put_contents($tmpPath, $content); try { $safeFilename = $this->fileValidator->sanitizeFilename($filename); $mime = $this->fileValidator->detectMimeType($tmpPath); $year = date('Y'); $month = date('m'); $dir = $this->projectDir . '/public/uploads/' . $subDir . '/' . $year . '-' . $month; if (!is_dir($dir)) mkdir($dir, 0755, true); $storedName = uniqid('', true) . '_' . $safeFilename; rename($tmpPath, $dir . '/' . $storedName); $url = '/uploads/' . $subDir . '/' . $year . '-' . $month . '/' . $storedName; return $this->success([ 'fid' => time(), 'uuid' => Uuid::v4()->toRfc4122(), 'url' => $url, 'filename' => $safeFilename, 'filemime' => $mime, 'filesize' => strlen($content), ]); } catch (\Throwable $e) { if (file_exists($tmpPath)) unlink($tmpPath); return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422); } } }