Files
clinicpro/.claude/prompt/fix-access-scoping-doctor-clinic-representation.md
T

168 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# رفع مشکلات دسترسی: پزشکِ عضو کلینیک + 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، داده‌ی نماینده‌ی دیگر را ببیند.