# رفع مشکلات دسترسی: پزشکِ عضو کلینیک + scope نماینده ## پروژه `clinicpro` (Backend auth/permission + Admin React SPA). کاملاً داخل همین پروژه است. ## زمینه دو نشتیِ دسترسی در پنل ادمین وجود دارد: 1. **پزشکِ عضو کلینیک، دسترسی کاملِ مالک کلینیک می‌گیرد.** وقتی یک پزشک از طریق دعوتنامه به کلینیک اضافه می‌شود، در `buildAvailableContexts` برایش یک context با `'role' => 'clinic'` ساخته می‌شود. چون `switchContext` در فرانت `primaryRole = context.role` می‌گذارد، آن پزشک با سوییچ به این context عملاً «مالک کلینیک» می‌شود و به **کل پنل کلینیک** (پرسنل، مالی، خدمات، اشتراک، ویرایش کلینیک و...) دسترسی پیدا می‌کند. درست این است که او فقط «پزشکِ شاغل در آن کلینیک» باشد (نوبت‌های خودش در آن کلینیک)، نه مدیر کلینیک. 2. **نماینده همه‌ی پزشکان/کلینیک‌ها را می‌بیند، نه فقط مالِ خودش.** صفحات `DoctorsPage`/`ClinicsPage` در حالت نماینده از endpointهای **عمومی** `/api/v1/doctors` و `/api/v1/clinics` استفاده می‌کنند که هیچ فیلتری روی `representation_id` ندارند. ضمناً `RepresentationActionController::createClinic` هنگام ساخت کلینیک، `representation_id` را ست **نمی‌کند** (فقط createDoctor این کار را می‌کند). نتیجه: نماینده همه‌ی دکترها/کلینیک‌های سیستم را می‌بیند و کلینیک‌های خودش هم تگ‌گذاری نمی‌شوند. ## مشکل / هدف - پزشکِ عضو کلینیک نباید نقش/دسترسی مالک کلینیک بگیرد. - نماینده فقط باید پزشکان و کلینیک‌هایی را ببیند که `representation_id` آن‌ها = id همان نماینده است (همان‌هایی که خودش ثبت کرده). ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Auth/Controller/AuthController.php` | `buildAvailableContexts()` — ساخت context پزشکِ عضو کلینیک با role اشتباه `clinic` | | `assets/admin/stores/authStore.ts` | `switchContext` → `primaryRole = context.role`؛ و type نقش | | `assets/admin/components/layout/Sidebar.tsx` | منوی هر نقش (باید برای پزشکِ مهمانِ کلینیک محدود باشد) | | `assets/admin/App.tsx` | `RoleRoute` صفحات کلینیک | | `src/Representation/Controller/RepresentationActionController.php` | `createClinic` که `representation_id` ست نمی‌کند؛ مرجع `createDoctor` که می‌کند | | `src/Doctor/Repository/DoctorRepository.php` | `findWithFilters` — فاقد فیلتر `representation` | | `src/Clinic/Repository/ClinicRepository.php` | `findWithFilters` — فاقد فیلتر `representation` | | `src/Doctor/Controller/DoctorController.php` / `src/Clinic/Controller/ClinicController.php` | endpointهای عمومی `GET /api/v1/doctors` و `/api/v1/clinics` | | `assets/admin/pages/DoctorsPage.tsx` / `ClinicsPage.tsx` | لیست نماینده که از endpoint عمومی بدون scope استفاده می‌کند | | `docs/api/auth.md`, `docs/api/doctor.md`, `docs/api/clinic.md`, `docs/api/representation.md` | مستندسازی | ## وضعیت فعلی (کد واقعی) `buildAvailableContexts` — context پزشکِ عضو کلینیک با role اشتباه: ```php if ($doctor = $this->doctorRepo->findByUser($user)) { $contexts[] = [ 'type'=>'doctor', 'db_uuid'=>$doctor->getUuid(), 'name'=>'مطب شخصی '.$doctor->getName(), 'role'=>'doctor' ]; foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { $contexts[] = [ 'type' => 'clinic', 'db_uuid' => $clinic->getUuid(), 'name' => $clinic->getName() ?? '', 'role' => 'clinic', // ← اشتباه: پزشکِ عضو، نقش مالک کلینیک می‌گیرد 'doctor_uuid' => $doctor->getUuid(), ]; } } // مالک واقعی کلینیک (این درست است — فقط وقتی clinic.user === خود کاربر) if ($clinic = $this->clinicRepo->findByUser($user)) { ... 'role'=>'clinic' ... } ``` `switchContext` در authStore (نقش از context می‌آید): ```ts primaryRole: res.data.context?.role ?? null, ``` `RepresentationActionController::createClinic` — بدون ست representation_id: ```php $clinic = new Clinic($ownerUser); $clinic->setName($name); // ... telephone/address/info $this->em->persist($clinic); // ← representation_id ست نمی‌شود ``` (در مقابل، `createDoctor` این را دارد: `$doctor->setRepresentationId($rep->getId());`) `DoctorsPage`/`ClinicsPage` (حالت نماینده، بدون scope): ```ts const base = isRepresentation ? '/api/v1/doctors' : '/api/v1/admin/doctors'; // عمومی، بدون representation const listBase = isRepresentation ? '/api/v1/clinics' : '/api/v1/admin/clinics'; // عمومی، بدون representation ``` ## وظایف ### ۱. نقشِ محدود برای پزشکِ عضو کلینیک (رفع نشتی دسترسی) در `buildAvailableContexts`، context کلینیک برای **پزشکِ عضو** باید نقش مالک کلینیک ندهد. یک نقش/scope محدود بده، مثلاً `role => 'doctor'` با `scope => 'clinic'` (یعنی همان پزشک، ولی در محیط آن کلینیک — برای دیدن نوبت‌های خودش در آن کلینیک): ```php foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { $contexts[] = [ 'type' => 'clinic', 'db_uuid' => $clinic->getUuid(), 'name' => $clinic->getName() ?? '', 'role' => 'doctor', // پزشک می‌ماند، نه مالک کلینیک 'scope' => 'clinic', 'doctor_uuid' => $doctor->getUuid(), ]; } ``` - بخش «صاحب کلینیک» (`$this->clinicRepo->findByUser($user)`) باید **دست‌نخورده** بماند و همان `role => 'clinic'` را داشته باشد — فقط مالک واقعی، نقش کامل کلینیک می‌گیرد. - **نکته‌ی مهم Backend:** هر endpointی که فرض می‌کند «کاربرِ با context کلینیک = مالک کلینیک» باید بازبینی شود تا با scope محدود سازگار باشد (دسترسی نوشتن روی پرسنل/خدمات/اشتراک/ویرایش کلینیک نباید برای پزشکِ مهمان باز باشد). مالکیت را با `clinic.getUser()->getId() === $user->getId()` چک کن (الگوی موجود در `ClinicInvitationController` خط ۲۰۰). ### ۲. Admin SPA — منو و مسیرهای محدود برای پزشکِ مهمانِ کلینیک چون با اصلاح وظیفه ۱ نقش این کاربر در آن context `doctor` می‌شود (نه `clinic`)، `Sidebar.tsx` و `RoleRoute`های `App.tsx` خودبه‌خود منوی پزشک را نشان می‌دهند و صفحات اختصاصی کلینیک (`/admin/staff`, `/admin/my-financial` با نقش clinic، ویرایش کلینیک، اشتراک) برای او باز نمی‌شوند. این را **تأیید کن**: - در `App.tsx` مطمئن شو صفحاتی مثل `clinics/:uuid` (ClinicDetailPage)، `staff`, `subscription` برای نقش `doctor` فقط در حدی باز است که منطقی است (مشاهده، نه مدیریت). اگر صفحه‌ای با `roles={['doctor','clinic']}` هست که نباید برای پزشکِ مهمان نوشتنی باشد، در همان صفحه بر اساس مالکیت (context.role === 'clinic') دکمه‌های مدیریتی را پنهان کن. - اگر `scope` در context هست، می‌توان در فرانت از `context.scope === 'clinic'` برای تمایز «پزشک در محیط کلینیک» استفاده کرد. ### ۳. نماینده — ست‌کردن representation_id روی کلینیک در `RepresentationActionController::createClinic`، مثل createDoctor مالکیت نماینده را ست کن: ```php $clinic = new Clinic($ownerUser); $clinic->setName($name); // ... $rep = $this->representationRepo->findByUser($user); if ($rep !== null) { $clinic->setRepresentationId($rep->getId()); } $this->em->persist($clinic); ``` ### ۴. فیلتر `representation` در لیست پزشکان و کلینیک‌ها در `DoctorRepository::findWithFilters` و `ClinicRepository::findWithFilters` یک فیلتر اختیاری `representation` اضافه کن: ```php // DoctorRepository if (!empty($filters['representation'])) { $qb->andWhere('d.representationId = :rep')->setParameter('rep', (int) $filters['representation']); } // ClinicRepository if (!empty($filters['representation'])) { $qb->andWhere('c.representationId = :rep')->setParameter('rep', (int) $filters['representation']); } ``` - در controllerهای عمومی `GET /api/v1/doctors` و `GET /api/v1/clinics`، پارامتر `representation` از query عبور داده شود به `findWithFilters` (همان الگوی بقیه‌ی فیلترها). چون این پارامتر اختیاری است، رفتار عمومی سایت تغییری نمی‌کند. > هشدار امنیتی: نماینده نباید بتواند با دست‌کاری `representation` در query، لیست نماینده‌ی دیگری را ببیند. بهترین کار: یک endpoint اختصاصیِ scoped برای نماینده بساز که id را از کاربر جاری می‌گیرد (مثل `/api/v1/representation/doctors` و `/api/v1/representation/clinics` در همان `RepresentationActionController` با `findByUser`)، نه از query. این امن‌تر از پارامتر عمومی است. (پیشنهادِ ترجیحی.) ### ۵. Admin SPA — اتصال لیست‌های نماینده به endpoint scoped در `DoctorsPage.tsx` و `ClinicsPage.tsx` حالت نماینده را به endpoint scoped (وظیفه ۴) وصل کن: ```ts // به‌جای /api/v1/doctors عمومی: const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors'; // به‌جای /api/v1/clinics عمومی: const listBase = isRepresentation ? '/api/v1/representation/clinics' : '/api/v1/admin/clinics'; ``` - adapter نگاشت شکل پاسخ (که قبلاً برای DoctorsPage اضافه شده) را با شکل خروجی endpoint جدید هماهنگ کن. ساده‌ترین کار: endpoint scoped همان شکل `/api/v1/admin/doctors` و `/api/v1/admin/clinics` را برگرداند تا نیازی به adapter نباشد. ### ۶. داشبورد نماینده — اعداد فقط از دامنه‌ی خودش مطمئن شو کارت‌ها/آمار داشبورد نماینده (`RepresentationDashboard` در `DashboardPage.tsx`) از endpointهای نماینده می‌آیند که از قبل scoped هستند (`/representation/{uuid}/dashboard/*` و `/representation/appointments`). اگر شمارش «تعداد پزشکان من / کلینیک‌های من» اضافه می‌شود، از همان endpoint scoped جدید بگیر (نه عمومی). ### ۷. مستندسازی - `docs/api/auth.md`: در توضیح `available_contexts`/`primary_role`، تفاوت «مالک کلینیک» (`role: clinic`) و «پزشکِ عضو در محیط کلینیک» (`role: doctor`, `scope: clinic`) را شرح بده. - `docs/api/doctor.md` و `docs/api/clinic.md`: پارامتر/endpoint جدید فیلتر `representation`. - `docs/api/representation.md`: endpointهای جدید `GET /api/v1/representation/doctors` و `/clinics` (اگر مسیر scoped انتخاب شد)؛ و اینکه createClinic حالا `representation_id` ست می‌کند. ## نکات مهم - **امنیت اول:** مالکیت همیشه از `#[CurrentUser]` تعیین شود؛ هرگز به `representation` در query برای scope اعتماد نکن (وظیفه ۴ پیشنهادِ endpoint scoped را ترجیح می‌دهد). - `Clinic.representationId` و `Doctor.representationId` ستون scalar هستند؛ فیلتر مستقیم روی همان ستون. - نقش `clinic` در سیستم = «مالک/مدیر کلینیک». پزشکِ عضو هرگز نباید این نقش را در context بگیرد. - تغییر `buildAvailableContexts` رفتار سوییچ context را برای پزشکانِ چندکلینیکه عوض می‌کند؛ تست کن که مالک واقعی کلینیک هنوز نقش کامل دارد و پزشکِ مهمان فقط نوبت‌های خودش را می‌بیند. - بعد از تغییر هر controller/مسیر، `cache:clear` و به‌روزرسانی `docs/api/*` در همان session. - migration لازم نیست (هر دو ستون `representation_id` از قبل وجود دارند؛ این تسک فقط منطق/فیلتر/نقش است). - **تست‌ها** (با `lexik:jwt:generate-token`): - پزشکی که عضو یک کلینیک است → `available_contexts` آن کلینیک باید `role: doctor`/`scope: clinic` باشد، نه `clinic`؛ و بعد از switch، منوی پزشک ببیند نه پنل کامل کلینیک. - مالک واقعی کلینیک → همچنان `role: clinic` و دسترسی کامل. - نماینده → `GET /api/v1/representation/doctors|clinics` فقط ردیف‌های با `representation_id = نماینده‌ی جاری`؛ و کلینیکِ تازه‌ساخته توسط نماینده باید `representation_id` داشته باشد. - نماینده نتواند با تغییر query، داده‌ی نماینده‌ی دیگر را ببیند.