diff --git a/.claude/prompt/secretary-context-scope.md b/.claude/prompt/secretary-context-scope.md new file mode 100644 index 00000000..df4a5d75 --- /dev/null +++ b/.claude/prompt/secretary-context-scope.md @@ -0,0 +1,314 @@ +# منطق scope منشی: تفکیک کلینیک از مطب شخصی + +## زمینه + +در حال حاضر `DoctorSecretary` فقط یک رابطه `(doctor_id, secretary_id)` دارد. اگر منشی A به دکتر X اختصاص داده شود — فرق نمی‌کند از طرف کلینیک باشد یا از طرف خود دکتر — در همه جا یکسان رفتار می‌شود. این باعث می‌شود: + +- منشی‌ای که کلینیک برای دکتر X تعریف کرده، به نوبت‌های مطب شخصی همان دکتر هم دسترسی داشته باشد. +- منشی‌ای که خود دکتر تعریف کرده، در داشبورد همه روابط کلینیکی او را هم می‌بیند. +- کلینیک می‌تواند منشی‌هایی را ببیند و حذف کند که دکتر شخصاً تعریف کرده است. + +## هدف + +اضافه کردن فیلد `owner_type` (`'doctor'` | `'clinic'`) و `clinic_id` (nullable FK) به `DoctorSecretary` تا هر رابطه منشی-پزشک به یک **scope** تعلق داشته باشد. + +قوانین: + +| ایجادکننده | `owner_type` | `clinic_id` | +|-----------|-------------|-------------| +| خود دکتر | `'doctor'` | `null` | +| کلینیک | `'clinic'` | `` | + +دسترسی‌ها، داشبورد، و فیلتر نوبت‌ها باید فقط در scope مربوطه اعمال شوند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود | +| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند | +| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند | +| `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبت‌های منشی — باید scope-aware شود | +| `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود | +| `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود | +| `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد | +| `migrations/` | migration جدید برای `owner_type` + `clinic_id` | +| `docs/api/secretary.md` | مستندات API | + +## وضعیت فعلی + +### Entity (بدون scope) +```php +// src/Secretary/Entity/DoctorSecretary.php +// هیچ فیلد owner_type یا clinic_id وجود ندارد +#[ORM\UniqueConstraint(name: 'idx_doctor_secretaries_pair', columns: ['doctor_id', 'secretary_id'])] +class DoctorSecretary +{ + #[ORM\ManyToOne(targetEntity: Doctor::class)] + private Doctor $doctor; + + #[ORM\ManyToOne(targetEntity: User::class)] + private User $secretary; + // ... +} +``` + +### Repository — scope-blind +```php +public function findActiveBySecretary(User $user): ?DoctorSecretary +{ + // فقط اولین رابطه فعال را برمی‌گرداند — scope در نظر گرفته نمی‌شود + return $this->findOneBy(['secretary' => $user, 'active' => true]); +} +``` + +### Controller — ایجاد بدون scope +```php +// create() در SecretaryController +// هیچ owner_type یا clinic_id ست نمی‌شود +$secretary = new DoctorSecretary($doctor, $secretaryUser); +``` + +### Auth context builder — scope-blind +```php +// AuthController::buildContexts() +foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { + $contexts[] = [ + 'type' => 'doctor', + 'db_uuid' => $rel->getDoctor()->getUuid(), + 'name' => 'مطب ' . $rel->getDoctor()->getName(), + 'role' => 'secretary', + 'permissions' => $rel->getPermissions(), + ]; +} +// اگر منشی هم در کلینیک هم در مطب شخصی یک دکتر باشد، دو context با db_uuid یکسان می‌سازد +// و هیچ‌کدام نشان نمی‌دهند که کدام scope است +``` + +### Appointment فیلتر — فقط یک رابطه +```php +// MyAppointmentsController — منشی فقط به نوبت‌های یک دکتر دسترسی دارد +} elseif (in_array('ROLE_SECRETARY', $roles, true)) { + $rel = $this->secretaryRepo->findActiveBySecretary($user); // اولین رابطه + $qb->andWhere('a.doctor = :doctor') + ->setParameter('doctor', $rel->getDoctor()); +} +``` + +## وظایف + +### ۱. Entity: اضافه کردن `owner_type` و `clinic_id` + +```php +// src/Secretary/Entity/DoctorSecretary.php + +// unique constraint باید scope را هم در نظر بگیرد: +// یک منشی می‌تواند هم از طرف کلینیک هم از طرف خود دکتر به همان پزشک متصل باشد +#[ORM\UniqueConstraint( + name: 'idx_doctor_secretary_scope', + columns: ['doctor_id', 'secretary_id', 'owner_type', 'clinic_id'] +)] + +#[ORM\Column(name: 'owner_type', type: 'string', length: 10)] +private string $ownerType; // 'doctor' | 'clinic' + +#[ORM\ManyToOne(targetEntity: Clinic::class)] +#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] +private ?Clinic $clinic = null; + +public function __construct(Doctor $doctor, User $secretary, string $ownerType = 'doctor', ?Clinic $clinic = null) +{ + // ... + $this->ownerType = $ownerType; + $this->clinic = $clinic; +} + +public function getOwnerType(): string { return $this->ownerType; } +public function getClinic(): ?Clinic { return $this->clinic; } +``` + +در `toArray()` هم اضافه شود: +```php +'owner_type' => $this->ownerType, +'clinic_uuid' => $this->clinic?->getUuid(), +``` + +### ۲. Migration + +بعد از تغییر Entity: +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +unique constraint قدیمی `idx_doctor_secretaries_pair` باید drop شود و جدید جای آن را بگیرد. + +### ۳. Repository: query های scope-aware + +```php +// src/Secretary/Repository/DoctorSecretaryRepository.php + +/** منشی در context مطب شخصی (owner_type='doctor') */ +public function findActiveBySecretaryForDoctor(User $user, Doctor $doctor): ?DoctorSecretary +{ + return $this->findOneBy([ + 'secretary' => $user, + 'doctor' => $doctor, + 'ownerType' => 'doctor', + 'active' => true, + ]); +} + +/** منشی در context کلینیک */ +public function findActiveBySecretaryForClinic(User $user, Clinic $clinic): ?DoctorSecretary +{ + // یک منشی می‌تواند به چند دکتر در یک کلینیک متصل باشد — اولین را برگردان برای چک دسترسی کلی + return $this->createQueryBuilder('s') + ->where('s.secretary = :user') + ->andWhere('s.clinic = :clinic') + ->andWhere('s.ownerType = :type') + ->andWhere('s.active = true') + ->setParameter('user', $user) + ->setParameter('clinic', $clinic) + ->setParameter('type', 'clinic') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); +} + +/** همه روابط فعال یک منشی — برای auth context builder */ +public function findAllActiveBySecretary(User $user): array +{ + return $this->findBy(['secretary' => $user, 'active' => true]); +} + +/** findActiveBySecretary قدیمی — نگه داشته شود برای backward compat اما deprecated */ +public function findActiveBySecretary(User $user): ?DoctorSecretary +{ + return $this->findOneBy(['secretary' => $user, 'active' => true]); +} + +/** همه دکترهای یک کلینیک که این منشی به آن‌ها متصل است */ +public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): array +{ + return $this->createQueryBuilder('s') + ->select('d') + ->join('s.doctor', 'd') + ->where('s.secretary = :user') + ->andWhere('s.clinic = :clinic') + ->andWhere('s.ownerType = :type') + ->andWhere('s.active = true') + ->setParameter('user', $user) + ->setParameter('clinic', $clinic) + ->setParameter('type', 'clinic') + ->getQuery() + ->getResult(); +} +``` + +همچنین `findByClinic` باید فقط منشیان با `owner_type='clinic'` را برگرداند: +```php +public function findByClinic(Clinic $clinic): array +{ + return $this->createQueryBuilder('s') + ->join('s.doctor', 'd') + ->where('s.clinic = :clinic') // اصلاح: به جای d.id IN (:ids) + ->andWhere('s.ownerType = :type') + ->setParameter('clinic', $clinic) + ->setParameter('type', 'clinic') + ->orderBy('s.createdAt', 'DESC') + ->getQuery() + ->getResult(); +} +``` + +### ۴. SecretaryController: ایجاد با scope + +در `create()`: +- اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)` +- اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null` + +```php +if ($currentUser->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($currentUser); + $secretary = new DoctorSecretary($doctor, $secretaryUser, 'clinic', $clinic); +} else { + $secretary = new DoctorSecretary($doctor, $secretaryUser, 'doctor', null); +} +``` + +همچنین `canManageDoctor()` باید scope را چک کند: +- `ROLE_DOCTOR` فقط می‌تواند روابط `owner_type='doctor'` را مدیریت کند +- `ROLE_CLINIC` فقط می‌تواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند + +### ۵. Auth context builder: scope-aware + +در `AuthController::buildContexts()` هر رابطه باید context مجزا با `scope` داشته باشد: + +```php +foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { + if ($rel->getOwnerType() === 'clinic' && $rel->getClinic() !== null) { + // context کلینیک — اگر قبلاً اضافه نشده + $clinicUuid = $rel->getClinic()->getUuid(); + $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinicUuid && $c['role'] === 'secretary_clinic'); + if (empty($alreadyAdded)) { + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinicUuid, + 'name' => 'کلینیک ' . $rel->getClinic()->getName(), + 'role' => 'secretary', + 'scope' => 'clinic', + 'permissions' => $rel->getPermissions(), + ]; + } + } else { + // context مطب شخصی + $contexts[] = [ + 'type' => 'doctor', + 'db_uuid' => $rel->getDoctor()->getUuid(), + 'name' => 'مطب ' . $rel->getDoctor()->getName(), + 'role' => 'secretary', + 'scope' => 'doctor', + 'permissions' => $rel->getPermissions(), + ]; + } +} +``` + +### ۶. Appointment و Dashboard: scope-aware + +در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`: +- اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آن‌ها متصل است +- اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'` + +برای تشخیص context فعلی در runtime: JWT payload را parse کن (یا `db_uuid` را از request header بگیر اگر پیاده‌سازی شده) و سپس: + +```php +// اگر db_uuid کلینیک بود: +$doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic); +$qb->andWhere('a.doctor IN (:doctors)')->setParameter('doctors', $doctors); + +// اگر db_uuid دکتر بود: +$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); +$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $rel->getDoctor()); +``` + +### ۷. PatientController: scope-aware + +```php +// PatientController::resolveEntity() +if ($user->hasRole('ROLE_SECRETARY')) { + // تعیین scope از db_uuid در JWT + // اگر کلینیک: entityType='clinic' + // اگر مطب شخصی: entityType='doctor' +} +``` + +## نکات مهم + +- **Unique constraint جدید:** `(doctor_id, secretary_id, owner_type, clinic_id)` — یک منشی می‌تواند هم از کلینیک هم از خود دکتر به پزشک مشابه متصل باشد (دو ردیف جداگانه با `owner_type` متفاوت). +- **Migration:** فیلد `owner_type` باید default `'doctor'` داشته باشد تا رکوردهای قبلی break نشوند. `clinic_id` nullable است. +- **تشخیص scope در runtime:** JWT یا header باید `db_uuid` فعلی را حمل کند. ابتدا بررسی کن آیا این uuid متعلق به یک کلینیک است یا دکتر — با `clinicRepo->findByUuid()` و `doctorRepo->findByUuid()`. +- **`canManage` در SecretaryController:** رابطه `owner_type='clinic'` را فقط صاحب آن کلینیک می‌تواند ویرایش/حذف کند، نه خود دکتر. و بالعکس. +- **`findByClinic`** باید `clinic_id = :clinic` را به جای `d.id IN (:ids)` استفاده کند تا صحیح باشد. +- **Frontend:** فیلد `scope` را در context برگشتی از `/me` نمایش بده. در `authStore` نگه دار تا صفحات بدانند منشی در چه scope‌ای است. diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 62689726..a9eeabe0 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -2,6 +2,19 @@ > **Prefix:** `/api/v1/secretary`, `/api/v1/secretaries` +## مدل Scope + +هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیط‌های مختلف جلوگیری می‌کند: + +| Scope | `owner_type` | تعریف‌کننده | دسترسی | +|-------|-------------|-------------|---------| +| مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی | +| کلینیک | `clinic` | مدیر کلینیک | فقط نوبت‌ها و داده‌های کلینیک | + +- یک منشی می‌تواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا) +- منشی کلینیک می‌تواند به چند دکتر در همان کلینیک متصل باشد +- scope فعال در runtime از جدول `user_active_context` (db_uuid) خوانده می‌شود + Secretaries are linked to a doctor and have granular permissions controlling what they can do on behalf of the doctor. --- @@ -10,7 +23,7 @@ Secretaries are linked to a doctor and have granular permissions controlling wha Create a secretary for a doctor. -**Permission:** `ROLE_DOCTOR` (must own the doctor) | `ROLE_CLINIC` (must have the doctor in its clinic) | `ROLE_ADMIN` +**Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN` ### Request Body (`application/json`) ```json @@ -78,6 +91,8 @@ Create a secretary for a doctor. "mobile_number": "09123456789", "doctor_name": "دکتر احمد رضایی", "doctor_uuid": "...", + "owner_type": "doctor", + "clinic_uuid": null, "is_active": true, "permissions": { ... }, "created_at": 1717000000 @@ -85,6 +100,12 @@ Create a secretary for a doctor. } ``` +**مقادیر `owner_type`:** +| مقدار | معنی | +|-------|------| +| `doctor` | منشی توسط خود دکتر تعریف شده — فقط مطب شخصی | +| `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک | + ### Errors | Code | HTTP | Description | |------|------|-------------| @@ -257,8 +278,9 @@ Get all secretaries across **all doctors** of a clinic. ``` ### Notes -- یک منشی می‌تواند برای یک یا چند دکتر تعریف شود (جداگانه در جدول `doctor_secretaries`) -- این endpoint همه منشیان همه دکترهای کلینیک را یکجا برمی‌گرداند با ستون `doctor_name` برای تشخیص +- این endpoint فقط منشیانی را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند +- منشیانی که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند +- یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر) ### Errors | Code | HTTP | Description | diff --git a/migrations/Version20260615074107.php b/migrations/Version20260615074107.php new file mode 100644 index 00000000..e05ba805 --- /dev/null +++ b/migrations/Version20260615074107.php @@ -0,0 +1,39 @@ +addSql('DROP INDEX idx_doctor_secretaries_pair ON doctor_secretaries'); + $this->addSql('ALTER TABLE doctor_secretaries ADD owner_type VARCHAR(10) DEFAULT \'doctor\' NOT NULL, ADD clinic_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE doctor_secretaries ADD CONSTRAINT FK_8DF480E9CC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE'); + $this->addSql('CREATE INDEX IDX_8DF480E9CC22AD4 ON doctor_secretaries (clinic_id)'); + $this->addSql('CREATE UNIQUE INDEX idx_doctor_secretary_scope ON doctor_secretaries (doctor_id, secretary_id, owner_type)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE doctor_secretaries DROP FOREIGN KEY FK_8DF480E9CC22AD4'); + $this->addSql('DROP INDEX IDX_8DF480E9CC22AD4 ON doctor_secretaries'); + $this->addSql('DROP INDEX idx_doctor_secretary_scope ON doctor_secretaries'); + $this->addSql('ALTER TABLE doctor_secretaries DROP owner_type, DROP clinic_id'); + $this->addSql('CREATE UNIQUE INDEX idx_doctor_secretaries_pair ON doctor_secretaries (doctor_id, secretary_id)'); + } +} diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index c9bd614a..c0f40e1e 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -4,8 +4,10 @@ namespace App\Appointment\Controller; use App\Appointment\Entity\Appointment; use App\Auth\Entity\User; +use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; +use App\Secretary\Entity\DoctorSecretary; use App\Secretary\Repository\DoctorSecretaryRepository; use App\Shared\Controller\BaseController; use Doctrine\ORM\EntityManagerInterface; @@ -18,10 +20,11 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class MyAppointmentsController extends BaseController { public function __construct( - private readonly EntityManagerInterface $em, - private readonly DoctorRepository $doctorRepo, - private readonly ClinicRepository $clinicRepo, - private readonly DoctorSecretaryRepository $secretaryRepo, + private readonly EntityManagerInterface $em, + private readonly DoctorRepository $doctorRepo, + private readonly ClinicRepository $clinicRepo, + private readonly DoctorSecretaryRepository $secretaryRepo, + private readonly UserActiveContextRepository $contextRepo, ) {} #[Route('/api/v1/my/appointment', methods: ['POST'])] @@ -127,16 +130,22 @@ class MyAppointmentsController extends BaseController $qb->andWhere('a.doctor = :doctor') ->setParameter('doctor', $doctor); } elseif (in_array('ROLE_SECRETARY', $roles, true)) { - $rel = $this->secretaryRepo->findActiveBySecretary($user); - if ($rel === null) { + $filter = $this->resolveSecretaryFilter($user); + if ($filter === null) { return $this->paginated([], 0, $page, $limit); } - $canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false); + [$filterType, $filterValue, $canView] = $filter; if (!$canView) { return $this->paginated([], 0, $page, $limit); } - $qb->andWhere('a.doctor = :doctor') - ->setParameter('doctor', $rel->getDoctor()); + if ($filterType === 'clinic') { + $qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors') + ->andWhere('c = :clinic') + ->setParameter('clinic', $filterValue); + } else { + $qb->andWhere('a.doctor = :doctor') + ->setParameter('doctor', $filterValue); + } } else { return $this->paginated([], 0, $page, $limit); } @@ -217,9 +226,16 @@ class MyAppointmentsController extends BaseController $qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $doctor); } } elseif (in_array('ROLE_SECRETARY', $roles, true)) { - $rel = $this->secretaryRepo->findActiveBySecretary($user); - if ($rel) { - $qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $rel->getDoctor()); + $filter = $this->resolveSecretaryFilter($user); + if ($filter !== null) { + [$filterType, $filterValue] = $filter; + if ($filterType === 'clinic') { + $qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors') + ->andWhere('c = :clinic') + ->setParameter('clinic', $filterValue); + } else { + $qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $filterValue); + } } } @@ -244,4 +260,40 @@ class MyAppointmentsController extends BaseController 'cancelled' => $cancelled, ]); } + + /** + * تعیین فیلتر نوبت‌ها برای منشی بر اساس scope فعال: + * Returns [type, entity, canView] یا null اگر رابطه‌ای پیدا نشد. + * type: 'clinic' | 'doctor' + * entity: Clinic | Doctor + */ + private function resolveSecretaryFilter(User $user): ?array + { + $activeCtx = $this->contextRepo->findByUser($user); + $dbUuid = $activeCtx?->getDbUuid(); + + if ($dbUuid === null) { + return null; + } + + // بررسی scope کلینیک + $clinic = $this->clinicRepo->findByUuid($dbUuid); + if ($clinic !== null) { + $rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); + if ($rel === null) return null; + $canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false); + return ['clinic', $clinic, $canView]; + } + + // بررسی scope مطب شخصی + $doctor = $this->doctorRepo->findByUuid($dbUuid); + if ($doctor !== null) { + $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); + if ($rel === null) return null; + $canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false); + return ['doctor', $doctor, $canView]; + } + + return null; + } } diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 0f7294cd..53aaf1cb 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -649,15 +649,33 @@ class AuthController extends BaseController } } - // منشی: همه روابط فعال + // منشی: هر رابطه فعال با scope مجزا foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { - $contexts[] = [ - 'type' => 'doctor', - 'db_uuid' => $rel->getDoctor()->getUuid(), - 'name' => 'مطب ' . $rel->getDoctor()->getName(), - 'role' => 'secretary', - 'permissions' => $rel->getPermissions(), - ]; + if ($rel->getOwnerType() === \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC && $rel->getClinic() !== null) { + // scope کلینیک — یک context به ازای هر کلینیک (نه هر دکتر) + $clinicUuid = $rel->getClinic()->getUuid(); + $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinicUuid && ($c['role'] ?? '') === 'secretary'); + if (empty($alreadyAdded)) { + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinicUuid, + 'name' => 'کلینیک ' . ($rel->getClinic()->getName() ?? ''), + 'role' => 'secretary', + 'scope' => 'clinic', + 'permissions' => $rel->getPermissions(), + ]; + } + } else { + // scope مطب شخصی + $contexts[] = [ + 'type' => 'doctor', + 'db_uuid' => $rel->getDoctor()->getUuid(), + 'name' => 'مطب ' . $rel->getDoctor()->getName(), + 'role' => 'secretary', + 'scope' => 'doctor', + 'permissions' => $rel->getPermissions(), + ]; + } } return $contexts; diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php index e11ae1b1..e4690752 100644 --- a/src/Dashboard/Controller/DashboardController.php +++ b/src/Dashboard/Controller/DashboardController.php @@ -3,10 +3,12 @@ namespace App\Dashboard\Controller; use App\Auth\Entity\User; +use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; use App\Patient\Repository\PatientRecordRepository; use App\Patient\Repository\PatientSessionRepository; +use App\Secretary\Entity\DoctorSecretary; use App\Secretary\Repository\DoctorSecretaryRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; @@ -21,13 +23,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class DashboardController extends BaseController { public function __construct( - private readonly ClinicRepository $clinicRepo, - private readonly DoctorRepository $doctorRepo, - private readonly DoctorSecretaryRepository $secretaryRepo, - private readonly EntityManagerInterface $em, - private readonly SmsWalletService $smsWalletService, - private readonly PatientRecordRepository $patientRecordRepo, - private readonly PatientSessionRepository $patientSessionRepo, + private readonly ClinicRepository $clinicRepo, + private readonly DoctorRepository $doctorRepo, + private readonly DoctorSecretaryRepository $secretaryRepo, + private readonly EntityManagerInterface $em, + private readonly SmsWalletService $smsWalletService, + private readonly PatientRecordRepository $patientRecordRepo, + private readonly PatientSessionRepository $patientSessionRepo, + private readonly UserActiveContextRepository $contextRepo, ) {} // ── Clinic Dashboard ──────────────────────────────────────────────────── @@ -250,11 +253,33 @@ class DashboardController extends BaseController #[IsGranted('ROLE_SECRETARY')] public function secretary(#[CurrentUser] User $user): JsonResponse { - $rel = $this->secretaryRepo->findActiveBySecretary($user); - if ($rel === null) { + $activeCtx = $this->contextRepo->findByUser($user); + $dbUuid = $activeCtx?->getDbUuid(); + + if ($dbUuid === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); } + // تعیین scope بر اساس db_uuid فعال + $clinic = $this->clinicRepo->findByUuid($dbUuid); + if ($clinic !== null) { + return $this->secretaryClinicDashboard($user, $clinic); + } + + $doctor = $this->doctorRepo->findByUuid($dbUuid); + if ($doctor !== null) { + $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); + if ($rel === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); + } + return $this->secretaryDoctorDashboard($user, $rel); + } + + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'context نامعتبر است', 403); + } + + private function secretaryDoctorDashboard(User $user, DoctorSecretary $rel): JsonResponse + { $doctor = $rel->getDoctor(); $permissions = $rel->getPermissions(); $canView = (bool) ($permissions['resources']['appointments']['view'] ?? false); @@ -293,6 +318,7 @@ class DashboardController extends BaseController } return $this->success([ + 'scope' => 'doctor', 'doctor' => [ 'uuid' => $doctor->getUuid(), 'name' => $doctor->getName(), @@ -306,4 +332,71 @@ class DashboardController extends BaseController 'today_appointments' => $todayAppts, ]); } + + private function secretaryClinicDashboard(User $user, \App\Clinic\Entity\Clinic $clinic): JsonResponse + { + $rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); + if ($rel === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); + } + + $permissions = $rel->getPermissions(); + $canView = (bool) ($permissions['resources']['appointments']['view'] ?? false); + + $todayStart = strtotime('today midnight'); + $todayEnd = strtotime('tomorrow midnight') - 1; + $tmrStart = strtotime('tomorrow midnight'); + $tmrEnd = strtotime('tomorrow midnight') + 86399; + + $doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic); + + $todayCount = 0; + $tmrCount = 0; + $todayAppts = []; + + if (!empty($doctors)) { + $todayCount = (int) $this->em->createQuery(' + SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a + WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e + ')->setParameters(['doctors' => $doctors, 's' => $todayStart, 'e' => $todayEnd]) + ->getSingleScalarResult(); + + $tmrCount = (int) $this->em->createQuery(' + SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a + WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e + ')->setParameters(['doctors' => $doctors, 's' => $tmrStart, 'e' => $tmrEnd]) + ->getSingleScalarResult(); + + if ($canView) { + $todayAppts = $this->em->createQuery(' + SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile, + a.slotStart AS slot_start, a.status, d.name AS doctor_name + FROM App\Appointment\Entity\Appointment a + JOIN a.user u + JOIN a.doctor d + WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e + ORDER BY a.slotStart ASC + ')->setMaxResults(20)->setParameters([ + 'doctors' => $doctors, + 's' => $todayStart, + 'e' => $todayEnd, + ])->getArrayResult(); + } + } + + return $this->success([ + 'scope' => 'clinic', + 'clinic' => [ + 'uuid' => $clinic->getUuid(), + 'name' => $clinic->getName(), + ], + 'permissions' => $permissions, + 'stats' => [ + 'today_appointments' => $todayCount, + 'tomorrow_appointments' => $tmrCount, + ], + 'today_appointments' => $todayAppts, + ]); + } } + diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 2fbe3e15..4641365b 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -3,6 +3,7 @@ namespace App\Patient\Controller; use App\Auth\Entity\User; +use App\Auth\Repository\UserActiveContextRepository; use App\Auth\Repository\UserRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; @@ -25,14 +26,15 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class PatientController extends BaseController { public function __construct( - private readonly PatientRecordRepository $recordRepo, - private readonly PatientSessionRepository $sessionRepo, - private readonly PatientService $patientService, - private readonly SubscriptionService $subscriptionService, - private readonly UserRepository $userRepo, - private readonly DoctorRepository $doctorRepo, - private readonly ClinicRepository $clinicRepo, - private readonly DoctorSecretaryRepository $secretaryRepo, + private readonly PatientRecordRepository $recordRepo, + private readonly PatientSessionRepository $sessionRepo, + private readonly PatientService $patientService, + private readonly SubscriptionService $subscriptionService, + private readonly UserRepository $userRepo, + private readonly DoctorRepository $doctorRepo, + private readonly ClinicRepository $clinicRepo, + private readonly DoctorSecretaryRepository $secretaryRepo, + private readonly UserActiveContextRepository $contextRepo, ) {} #[Route('/api/v1/patients', methods: ['GET'])] @@ -173,9 +175,22 @@ class PatientController extends BaseController } if ($user->hasRole('ROLE_SECRETARY')) { - $secretary = $this->secretaryRepo->findActiveBySecretary($user); - if ($secretary !== null && ($secretary->getPermissions()['appointments']['view'] ?? false)) { - return ['doctor', $secretary->getDoctor()->getId()]; + $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); + if ($dbUuid !== null) { + $clinic = $this->clinicRepo->findByUuid($dbUuid); + if ($clinic !== null) { + $rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); + if ($rel !== null) { + return ['clinic', $clinic->getId()]; + } + } + $doctor = $this->doctorRepo->findByUuid($dbUuid); + if ($doctor !== null) { + $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); + if ($rel !== null) { + return ['doctor', $doctor->getId()]; + } + } } } diff --git a/src/Secretary/Controller/SecretaryController.php b/src/Secretary/Controller/SecretaryController.php index 445b9b04..b7eb200d 100644 --- a/src/Secretary/Controller/SecretaryController.php +++ b/src/Secretary/Controller/SecretaryController.php @@ -4,9 +4,7 @@ namespace App\Secretary\Controller; use App\Auth\Entity\User; use App\Auth\Repository\UserRepository; -use App\Clinic\Entity\Clinic; use App\Clinic\Repository\ClinicRepository; -use App\Doctor\Entity\Doctor; use App\Doctor\Repository\DoctorRepository; use App\Secretary\Entity\DoctorSecretary; use App\Secretary\Repository\DoctorSecretaryRepository; @@ -48,14 +46,24 @@ class SecretaryController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if (!$this->canManageDoctor($doctor, $currentUser)) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + // تعیین scope بر اساس نقش کاربر جاری + $ownerClinic = null; + if ($currentUser->hasRole('ROLE_CLINIC')) { + $ownerClinic = $this->clinicRepo->findByUser($currentUser); + if ($ownerClinic === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $ownerClinic)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + } elseif (!$currentUser->hasRole('ROLE_ADMIN')) { + // دکتر فقط برای خودش منشی تعریف می‌کند + if ($doctor->getUser()->getId() !== $currentUser->getId()) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } } - // Check plan limit (dynamic via SubscriptionService) - $entityType = 'doctor'; - $entityId = $doctor->getId(); - $limit = $this->subscriptionService->getSecretaryLimit($entityType, $entityId); + $ownerType = $ownerClinic !== null ? DoctorSecretary::OWNER_CLINIC : DoctorSecretary::OWNER_DOCTOR; + + // Check plan limit + $limit = $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId()); $activeCount = $this->secretaryRepo->countActiveByDoctor($doctor); if ($activeCount >= $limit) { return $this->error(ErrorCodes::ERR_SECRETARY_001, ErrorCodes::message(ErrorCodes::ERR_SECRETARY_001), 422); @@ -65,7 +73,6 @@ class SecretaryController extends BaseController $secretaryUser = $this->userRepo->findByMobile($mobile); if ($secretaryUser === null) { $secretaryUser = new User($mobile); - // Set a temporary password if provided if (!empty($data['password'])) { $hash = $this->hasher->hashPassword($secretaryUser, $data['password']); $secretaryUser->setPasswordHash($hash); @@ -80,15 +87,18 @@ class SecretaryController extends BaseController } $this->userRepo->save($secretaryUser); - // Check duplicate - $existing = $this->secretaryRepo->findOneBy(['doctor' => $doctor, 'secretary' => $secretaryUser]); + // Check duplicate within same scope + $existing = $this->secretaryRepo->findOneBy([ + 'doctor' => $doctor, + 'secretary' => $secretaryUser, + 'ownerType' => $ownerType, + ]); if ($existing !== null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این منشی قبلاً اضافه شده است', 409); } - $secretary = new DoctorSecretary($doctor, $secretaryUser); + $secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerType, $ownerClinic); - // Apply custom permissions if provided if (!empty($data['permissions'])) { $secretary->mergePermissions($data['permissions']); } @@ -157,6 +167,7 @@ class SecretaryController extends BaseController return $this->success(['message' => 'منشی با موفقیت حذف شد']); } + /** لیست منشیان مطب شخصی یک دکتر */ #[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])] public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse { @@ -165,18 +176,20 @@ class SecretaryController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if (!$this->canManageDoctor($doctor, $currentUser)) { + // فقط خود دکتر یا ادمین می‌توانند منشیان مطب شخصی را ببینند + if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } $secretaries = array_map( fn(DoctorSecretary $s) => $s->toArray(), - $this->secretaryRepo->findByDoctor($doctor) + $this->secretaryRepo->findByDoctorScope($doctor) ); - return $this->success(['data' => $secretaries]); + return $this->success($secretaries); } + /** لیست همه منشیان کلینیک (از همه دکترها، owner_type='clinic') */ #[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])] public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse { @@ -194,30 +207,28 @@ class SecretaryController extends BaseController $this->secretaryRepo->findByClinic($clinic) ); - return $this->success(['data' => $secretaries]); + return $this->success($secretaries); } + /** بررسی دسترسی برای ویرایش/حذف یک رابطه منشی — scope-aware */ private function canManage(DoctorSecretary $secretary, User $user): bool - { - return $this->canManageDoctor($secretary->getDoctor(), $user); - } - - private function canManageDoctor(Doctor $doctor, User $user): bool { if ($user->hasRole('ROLE_ADMIN')) { return true; } - if ($doctor->getUser()->getId() === $user->getId()) { - return true; + if ($secretary->getOwnerType() === DoctorSecretary::OWNER_DOCTOR) { + // فقط خود دکتر می‌تواند منشی مطب شخصی‌اش را مدیریت کند + return $secretary->getDoctor()->getUser()->getId() === $user->getId(); } - // clinic owner can manage secretaries of its own doctors - if ($user->hasRole('ROLE_CLINIC')) { - $clinic = $this->clinicRepo->findByUser($user); - if ($clinic !== null && $this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) { - return true; + if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) { + // فقط مالک کلینیک می‌تواند منشیان کلینیکی را مدیریت کند + if (!$user->hasRole('ROLE_CLINIC')) { + return false; } + $clinic = $this->clinicRepo->findByUser($user); + return $clinic !== null && $secretary->getClinic()?->getId() === $clinic->getId(); } return false; diff --git a/src/Secretary/Entity/DoctorSecretary.php b/src/Secretary/Entity/DoctorSecretary.php index 45d3fcc0..13d2a0dc 100644 --- a/src/Secretary/Entity/DoctorSecretary.php +++ b/src/Secretary/Entity/DoctorSecretary.php @@ -3,15 +3,19 @@ namespace App\Secretary\Entity; use App\Auth\Entity\User; +use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; #[ORM\Entity] #[ORM\Table(name: 'doctor_secretaries')] -#[ORM\UniqueConstraint(name: 'idx_doctor_secretaries_pair', columns: ['doctor_id', 'secretary_id'])] +#[ORM\UniqueConstraint(name: 'idx_doctor_secretary_scope', columns: ['doctor_id', 'secretary_id', 'owner_type'])] class DoctorSecretary { + public const OWNER_DOCTOR = 'doctor'; + public const OWNER_CLINIC = 'clinic'; + public const DEFAULT_PERMISSIONS = [ 'version' => 1, 'resources' => [ @@ -38,6 +42,13 @@ class DoctorSecretary #[ORM\JoinColumn(name: 'secretary_id', referencedColumnName: 'id', nullable: false)] private User $secretary; + #[ORM\Column(name: 'owner_type', type: 'string', length: 10, options: ['default' => 'doctor'])] + private string $ownerType; + + #[ORM\ManyToOne(targetEntity: Clinic::class)] + #[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Clinic $clinic = null; + #[ORM\Column(name: 'permission', type: 'json', nullable: true)] private ?array $permissions = null; @@ -50,25 +61,30 @@ class DoctorSecretary #[ORM\Column(name: 'updated_at', type: 'integer')] private int $updatedAt; - public function __construct(Doctor $doctor, User $secretary) + public function __construct(Doctor $doctor, User $secretary, string $ownerType = self::OWNER_DOCTOR, ?Clinic $clinic = null) { $this->uuid = Uuid::v4()->toRfc4122(); $this->doctor = $doctor; $this->secretary = $secretary; + $this->ownerType = $ownerType; + $this->clinic = $clinic; $this->permissions = self::DEFAULT_PERMISSIONS; $this->createdAt = time(); $this->updatedAt = time(); } - public function getId(): ?int { return $this->id; } - public function getUuid(): string { return $this->uuid; } - public function getDoctor(): Doctor { return $this->doctor; } - public function getSecretary(): User { return $this->secretary; } + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getSecretary(): User { return $this->secretary; } + public function getOwnerType(): string { return $this->ownerType; } + public function getClinic(): ?Clinic { return $this->clinic; } public function getPermissions(): array { return $this->permissions ?? self::DEFAULT_PERMISSIONS; } - public function isActive(): bool { return $this->active; } - public function getCreatedAt(): int { return $this->createdAt; } + public function isActive(): bool { return $this->active; } + public function getCreatedAt(): int { return $this->createdAt; } + public function getUpdatedAt(): int { return $this->updatedAt; } - public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } public function setPermissions(array $v): self { $this->permissions = $v; $this->touch(); return $this; } /** Deep merge: only provided resources/actions are updated */ @@ -102,6 +118,8 @@ class DoctorSecretary 'mobile_number' => $this->secretary->getMobileNumber(), 'doctor_name' => $this->doctor->getName(), 'doctor_uuid' => $this->doctor->getUuid(), + 'owner_type' => $this->ownerType, + 'clinic_uuid' => $this->clinic?->getUuid(), 'is_active' => $this->active, 'permissions' => $this->getPermissions(), 'created_at' => $this->createdAt, diff --git a/src/Secretary/Repository/DoctorSecretaryRepository.php b/src/Secretary/Repository/DoctorSecretaryRepository.php index f699f7cf..43542bf4 100644 --- a/src/Secretary/Repository/DoctorSecretaryRepository.php +++ b/src/Secretary/Repository/DoctorSecretaryRepository.php @@ -38,37 +38,87 @@ class DoctorSecretaryRepository extends ServiceEntityRepository return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']); } - public function isDoctorInClinic(Doctor $doctor, Clinic $clinic): bool + /** منشیان مطب شخصی یک دکتر (owner_type='doctor') */ + public function findByDoctorScope(Doctor $doctor): array { - return $clinic->getDoctors()->contains($doctor); + return $this->findBy( + ['doctor' => $doctor, 'ownerType' => DoctorSecretary::OWNER_DOCTOR], + ['createdAt' => 'DESC'] + ); } - /** @return DoctorSecretary[] — all secretaries across all doctors of a clinic */ - public function findByClinic(Clinic $clinic): array + /** رابطه منشی در scope مطب شخصی */ + public function findActiveBySecretaryForDoctor(User $user, Doctor $doctor): ?DoctorSecretary { - $doctorIds = $clinic->getDoctors()->map(fn(Doctor $d) => $d->getId())->toArray(); - if (empty($doctorIds)) { - return []; - } + return $this->findOneBy([ + 'secretary' => $user, + 'doctor' => $doctor, + 'ownerType' => DoctorSecretary::OWNER_DOCTOR, + 'active' => true, + ]); + } + /** اولین رابطه فعال منشی در یک کلینیک (برای چک دسترسی کلی) */ + public function findActiveBySecretaryForClinic(User $user, Clinic $clinic): ?DoctorSecretary + { return $this->createQueryBuilder('s') + ->where('s.secretary = :user') + ->andWhere('s.clinic = :clinic') + ->andWhere('s.ownerType = :type') + ->andWhere('s.active = true') + ->setParameter('user', $user) + ->setParameter('clinic', $clinic) + ->setParameter('type', DoctorSecretary::OWNER_CLINIC) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + /** همه دکترهای کلینیک که این منشی به آن‌ها متصل است */ + public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): array + { + return $this->createQueryBuilder('s') + ->select('d') ->join('s.doctor', 'd') - ->where('d.id IN (:ids)') - ->setParameter('ids', $doctorIds) - ->orderBy('s.createdAt', 'DESC') + ->where('s.secretary = :user') + ->andWhere('s.clinic = :clinic') + ->andWhere('s.ownerType = :type') + ->andWhere('s.active = true') + ->setParameter('user', $user) + ->setParameter('clinic', $clinic) + ->setParameter('type', DoctorSecretary::OWNER_CLINIC) ->getQuery() ->getResult(); } + /** همه روابط فعال یک منشی — برای auth context builder */ + public function findAllActiveBySecretary(User $user): array + { + return $this->findBy(['secretary' => $user, 'active' => true]); + } + + /** اولین رابطه فعال — backward compat برای کدهایی که هنوز migrate نشده‌اند */ public function findActiveBySecretary(User $user): ?DoctorSecretary { return $this->findOneBy(['secretary' => $user, 'active' => true]); } - /** @return DoctorSecretary[] */ - public function findAllActiveBySecretary(User $user): array + public function isDoctorInClinic(Doctor $doctor, Clinic $clinic): bool { - return $this->findBy(['secretary' => $user, 'active' => true]); + return $clinic->getDoctors()->contains($doctor); + } + + /** همه منشیان کلینیک (owner_type='clinic') */ + public function findByClinic(Clinic $clinic): array + { + return $this->createQueryBuilder('s') + ->where('s.clinic = :clinic') + ->andWhere('s.ownerType = :type') + ->setParameter('clinic', $clinic) + ->setParameter('type', DoctorSecretary::OWNER_CLINIC) + ->orderBy('s.createdAt', 'DESC') + ->getQuery() + ->getResult(); } public function save(DoctorSecretary $entity, bool $flush = true): void