# منطق 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‌ای است.