- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity. - Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting. - Adjusted API documentation to align with the new naming conventions. - Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic. - Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
854 lines
39 KiB
PHP
854 lines
39 KiB
PHP
<?php
|
|
|
|
namespace App\Doctor\Controller;
|
|
|
|
use App\Appointment\Repository\WeeklyScheduleRepository;
|
|
use App\Auth\Entity\User;
|
|
use App\Insurance\Entity\TenantInsurance;
|
|
use App\Insurance\Service\TenantInsuranceCleanupService;
|
|
use App\Auth\Repository\UserRepository;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Clinic\Repository\ClinicRepository;
|
|
use App\DoctorService\Repository\DoctorServiceRepository;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Doctor\Repository\DoctorAddressRepository;
|
|
use App\Doctor\Repository\DoctorRepository;
|
|
use App\Location\Repository\CityRepository;
|
|
use App\Location\Repository\ProvinceRepository;
|
|
use App\Shared\Constant\ErrorCodes;
|
|
use App\Shared\Controller\BaseController;
|
|
use App\Shared\Service\FileValidatorService;
|
|
use App\Specialty\Repository\SpecialtyRepository;
|
|
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;
|
|
use App\Shared\Util\PersianText;
|
|
|
|
#[OA\Tag(name: 'Doctors')]
|
|
class DoctorController extends BaseController
|
|
{
|
|
public function __construct(
|
|
private readonly DoctorRepository $doctorRepo,
|
|
private readonly DoctorAddressRepository $addressRepo,
|
|
private readonly ClinicRepository $clinicRepo,
|
|
private readonly SpecialtyRepository $specialtyRepo,
|
|
private readonly DoctorServiceRepository $serviceRepo,
|
|
private readonly ProvinceRepository $provinceRepo,
|
|
private readonly CityRepository $cityRepo,
|
|
private readonly UserRepository $userRepo,
|
|
private readonly FileValidatorService $fileValidator,
|
|
private readonly WeeklyScheduleRepository $scheduleRepo,
|
|
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
|
|
private readonly TenantInsuranceCleanupService $insuranceCleanup,
|
|
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
|
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
|
|
private readonly string $projectDir,
|
|
) {}
|
|
|
|
// ── Doctor CRUD ───────────────────────────────────────────────────────────
|
|
|
|
#[OA\Post(
|
|
path: '/api/v1/doctor',
|
|
summary: 'Create a new doctor profile',
|
|
security: [['bearerAuth' => []]],
|
|
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);
|
|
}
|
|
}
|
|
|
|
}
|