- Implement DoctorPermissionsModal for managing doctor permissions in clinics. - Create usePermissions hook to handle user permissions context. - Add migration for clinic_doctor_permissions table with default permissions. - Develop ClinicDoctorPermissionController for handling permissions API. - Create ClinicDoctorPermission entity to manage permissions data. - Implement ClinicDoctorPermissionRepository for database interactions. - Add ClinicDoctorPermissionChecker for permission validation logic. - Write tests for clinic doctor permissions functionality.
242 lines
16 KiB
Markdown
242 lines
16 KiB
Markdown
# مدیریت دسترسی پزشکان کلینیک (Per-doctor permissions)
|
||
|
||
## زمینه
|
||
|
||
پس از پذیرش دعوتنامه، پزشک صرفاً یک سطر در جدول join `clinic_doctors` میگیرد و هیچجا نمیتوان تعیین کرد که این پزشک در آن کلینیک به چه بخشهایی دسترسی دارد. امروز نقش او hardcode است: در `buildAvailableContexts()` پزشکِ غیرمالک، context کلینیک را با `role: 'doctor'` و `scope: 'clinic'` میگیرد و همین `scope` باعث میشود Sidebar فقط داشبورد و «نوبتهای من» را نشان دهد — بدون هیچ امکان تنظیم.
|
||
|
||
هدف: مدیر کلینیک بتواند از `/admin/settings/clinic-doctors` برای هر پزشک سطح دسترسی تعیین کند، و این دسترسی هم در بکاند اعمال شود هم منوی پنل را بسازد.
|
||
|
||
الگوی مرجع در پروژه: سیستم مجوز منشی (`DoctorSecretary`). عیناً همان envelope و همان الگوی UI را تکرار کن، **ولی سه ضعف آن را تکرار نکن** (در «نکات مهم» توضیح داده شده).
|
||
|
||
> این پرامپت پیشنیاز `clinic-appointment-settings-tabs.md` است. اول این را اجرا کن.
|
||
|
||
## مشکل / هدف
|
||
|
||
۱. جایی برای ذخیرهی مجوزِ «پزشک X در کلینیک Y» وجود ندارد.
|
||
۲. مجوزها به کلاینت ارسال نمیشوند (context پزشکِ عضو کلینیک فیلد `permissions` ندارد).
|
||
۳. هیچ primitive سمت فرانت برای gate کردن منو/صفحه بر اساس مجوز وجود ندارد (`FeatureGate` فقط اشتراک را چک میکند).
|
||
۴. صفحه `/admin/settings/clinic-doctors` برای هر پزشک فقط دو اکشن دارد: مشاهده پروفایل و جداسازی.
|
||
|
||
## فایلهای مرتبط
|
||
|
||
| فایل | نقش |
|
||
|------|-----|
|
||
| `src/Clinic/Entity/Clinic.php:88-95` | ManyToMany `clinic_doctors` — پیوند فعلی، بدون ستون اضافی |
|
||
| `src/Secretary/Entity/DoctorSecretary.php:20-30, 104-122, 140` | الگوی مرجع: envelope مجوز، `mergePermissions()`، `toArray()` |
|
||
| `src/Secretary/Security/SecretaryPermissionChecker.php` | چکر موجود — **کد مرده، هیچ call site ندارد** |
|
||
| `src/Auth/Controller/AuthController.php:694-765` | `buildAvailableContexts()` — جایی که باید `permissions` اضافه شود |
|
||
| `src/Clinic/Controller/ClinicController.php` | `GET /api/v1/clinic/doctor-list/{clinicUuid}`، `DELETE /api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}` |
|
||
| `assets/admin/components/ClinicDoctorsManager.tsx` | UI ردیف هر پزشک — محل دکمه مجوزها |
|
||
| `assets/admin/pages/SecretariesPage.tsx:15-22, 26+, 82-140` | الگوی مرجع `PermissionsMatrix` و `PERMISSION_LABELS` |
|
||
| `assets/admin/stores/authStore.ts:4-12` | `ContextItem.permissions?: Record<string, any>` — تعریف شده ولی هیچ مصرفکنندهای ندارد |
|
||
| `assets/admin/components/layout/Sidebar.tsx:52-76` | `buildSections(primaryRole, dbUuid, scope)` — منوی پزشکِ در scope کلینیک |
|
||
| `assets/admin/App.tsx:117-128` | `RoleRoute` + `blockClinicScope` |
|
||
| `docs/api/clinic.md` | مستند API کلینیک |
|
||
|
||
## وضعیت فعلی
|
||
|
||
پیوند کلینیک↔پزشک هیچ ستون اضافی ندارد — `src/Clinic/Entity/Clinic.php:88-95`:
|
||
|
||
```php
|
||
#[ORM\ManyToMany(targetEntity: Doctor::class)]
|
||
#[ORM\JoinTable(
|
||
name: 'clinic_doctors',
|
||
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||
inverseJoinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||
)]
|
||
private Collection $doctors;
|
||
```
|
||
|
||
context پزشکِ عضو کلینیک هیچ مجوزی حمل نمیکند — `src/Auth/Controller/AuthController.php:711-718`:
|
||
|
||
```php
|
||
$isOwner = $clinic->getUser()->getId() === $user->getId();
|
||
$contexts[] = [
|
||
'type' => 'clinic',
|
||
'db_uuid' => $clinic->getUuid(),
|
||
'name' => $clinic->getName() ?? '',
|
||
'role' => $isOwner ? 'clinic' : 'doctor',
|
||
'scope' => $isOwner ? null : 'clinic',
|
||
'doctor_uuid' => $doctor->getUuid(),
|
||
];
|
||
```
|
||
|
||
منوی پزشکِ در scope کلینیک hardcode است — `assets/admin/components/layout/Sidebar.tsx:52-76`:
|
||
|
||
```tsx
|
||
if (primaryRole === "doctor" && scope === "clinic") {
|
||
// فقط داشبورد و «نوبتهای من»
|
||
```
|
||
|
||
ردیف هر پزشک فقط دو اکشن دارد — `assets/admin/components/ClinicDoctorsManager.tsx`:
|
||
|
||
```tsx
|
||
<button className="mini-btn" title="مشاهده پروفایل"
|
||
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}>
|
||
<EyeIcon style={{ width: 14, height: 14 }} />
|
||
</button>
|
||
{!readOnly && (
|
||
<button className="mini-btn danger" title="جداسازی از کلینیک"
|
||
onClick={() => setDetachDoctorConfirm(doc)}>
|
||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||
</button>
|
||
)}
|
||
```
|
||
|
||
## وظایف
|
||
|
||
### ۱. Entity جدید `ClinicDoctorPermission`
|
||
|
||
**جدول `clinic_doctors` را به entity تبدیل نکن.** شش نقطه در کد به `Clinic::$doctors` (`getDoctors`/`hasDoctor`/`findByDoctor`/`isDoctorInClinic`/detach endpoint) وابستهاند و mapping همزمانِ ManyToMany و entity روی یک جدول، schema tool را دچار تعارض میکند. بهجایش یک جدول موازی بساز:
|
||
|
||
`src/Clinic/Entity/ClinicDoctorPermission.php` — جدول `clinic_doctor_permissions`:
|
||
|
||
| ستون | نوع | توضیح |
|
||
|---|---|---|
|
||
| `id` | int, auto | |
|
||
| `uuid` | string(36) unique | `Uuid::v4()->toRfc4122()` در constructor |
|
||
| `clinic_id` | ManyToOne Clinic, `nullable: false`, `onDelete: CASCADE` | |
|
||
| `doctor_id` | ManyToOne Doctor, `nullable: false`, `onDelete: CASCADE` | |
|
||
| `permission` | json | envelope `{version, resources}` |
|
||
| `active` | bool, default true | |
|
||
| `created_at` / `updated_at` | int (Unix) | |
|
||
|
||
`UniqueConstraint(['clinic_id','doctor_id'])`.
|
||
|
||
envelope پیشفرض — دقیقاً همشکل `DoctorSecretary::DEFAULT_PERMISSIONS` ولی با منابعِ مربوط به پزشک:
|
||
|
||
```php
|
||
public const DEFAULT_PERMISSIONS = [
|
||
'version' => 1,
|
||
'resources' => [
|
||
'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true],
|
||
'appointment_settings' => ['view' => true, 'update' => true],
|
||
'patients' => ['view' => true, 'create' => true, 'update' => true, 'delete' => false],
|
||
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||
'services' => ['view' => true, 'update' => false],
|
||
'clinic_info' => ['view' => true, 'update' => false],
|
||
],
|
||
];
|
||
```
|
||
|
||
متدها: `mergePermissions(array $partial)` (deep-merge با cast به bool — عیناً از `DoctorSecretary.php:104-122` الگو بگیر)، `getPermissions()`، `toArray()`.
|
||
|
||
**`toArray()` نباید envelope را flatten کند** — همان `{version, resources}` را برگردان تا کلاینت با دو شکل مختلف روبهرو نشود (ضعف فعلی سیستم منشی).
|
||
|
||
Migration بساز و اجرا کن. برای هر سطر موجود `clinic_doctors` یک سطر با مجوز پیشفرض seed کن (در همان migration یا با یک command).
|
||
|
||
### ۲. Repository + Service
|
||
|
||
`src/Clinic/Repository/ClinicDoctorPermissionRepository.php`:
|
||
- `findOneFor(Clinic $clinic, Doctor $doctor): ?ClinicDoctorPermission`
|
||
- `findByClinic(Clinic $clinic): array`
|
||
- `getOrCreate(Clinic $clinic, Doctor $doctor): ClinicDoctorPermission` — پزشکی که قبل از این feature عضو شده، سطر ندارد؛ در اولین دسترسی با مجوز پیشفرض ساخته شود.
|
||
|
||
### ۳. چکر مجوز — با call site واقعی
|
||
|
||
`src/Clinic/Security/ClinicDoctorPermissionChecker.php`:
|
||
|
||
```php
|
||
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
|
||
```
|
||
|
||
- مالک کلینیک و `ROLE_ADMIN` → همیشه `true`.
|
||
- در غیر اینصورت: پروفایل پزشکِ `$user` را بگیر، سطر مجوز را پیدا کن، `active` و `resources.$resource.$action` را برگردان. سطر نبود یا `active=false` → `false`.
|
||
- یک `assert(...)` هم داشته باشد که در صورت false، `AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403)` پرتاب کند.
|
||
|
||
**این کلاس باید واقعاً استفاده شود.** حداقل در endpointهای زیر آن را صدا بزن (نه فقط تعریف کن):
|
||
- `GET /api/v1/clinic/doctor-list/{clinicUuid}` → `clinic_info.view`
|
||
- تنظیمات نوبتدهی (در پرامپت دوم) → `appointment_settings.view` / `.update`
|
||
|
||
اگر منبعی هنوز endpoint متناظر ندارد، آن کلید را از `DEFAULT_PERMISSIONS` حذف کن — کلید ذخیرهشدهای که هرگز چک نمیشود، همان اشتباه سیستم منشی است.
|
||
|
||
### ۴. Endpointهای مدیریت مجوز
|
||
|
||
در `src/Clinic/Controller/ClinicController.php` (یا کنترلر جدید `ClinicDoctorPermissionController` اگر تمیزتر بود):
|
||
|
||
| Method | Path | دسترسی |
|
||
|---|---|---|
|
||
| `GET` | `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions` | مالک کلینیک یا `ROLE_ADMIN` |
|
||
| `PATCH` | `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions` | مالک کلینیک یا `ROLE_ADMIN` |
|
||
|
||
بدنه PATCH: `{ "permissions": { "appointments": { "cancel": false } } }` — deep-merge، نه جایگزینی کامل. `active` هم قابل تغییر باشد: `{ "active": false }`.
|
||
|
||
پاسخها با `$this->success($perm->toArray())`. اگر پزشک عضو این کلینیک نیست → `404` با `ERR_NOT_FOUND_001`.
|
||
|
||
بررسی دسترسی مالک: همان الگوی `assertClinicAccess()` در `src/ClinicInvitation/Controller/ClinicInvitationController.php:196-205`.
|
||
|
||
### ۵. انتشار مجوز در context
|
||
|
||
در `src/Auth/Controller/AuthController.php:711-718`، برای پزشکِ غیرمالک فیلد `permissions` را اضافه کن:
|
||
|
||
```php
|
||
$contexts[] = [
|
||
'type' => 'clinic',
|
||
'db_uuid' => $clinic->getUuid(),
|
||
'name' => $clinic->getName() ?? '',
|
||
'role' => $isOwner ? 'clinic' : 'doctor',
|
||
'scope' => $isOwner ? null : 'clinic',
|
||
'doctor_uuid' => $doctor->getUuid(),
|
||
'permissions' => $isOwner ? null : $this->clinicDoctorPermRepo->getOrCreate($clinic, $doctor)->getPermissions(),
|
||
];
|
||
```
|
||
|
||
مراقب N+1 باش: `buildAvailableContexts` روی همه کلینیکهای پزشک حلقه میزند — مجوزها را با یک کوئری برای همه کلینیکها بگیر و در آرایه نگاشت کن.
|
||
|
||
### ۶. `usePermissions` سمت فرانت
|
||
|
||
`assets/admin/hooks/usePermissions.ts` — primitive تازه (امروز اصلاً وجود ندارد):
|
||
|
||
```ts
|
||
export function usePermissions() {
|
||
const context = useAuthStore(s => s.context);
|
||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||
|
||
const can = useCallback((resource: string, action: string): boolean => {
|
||
if (primaryRole === 'admin' || primaryRole === 'clinic') return true;
|
||
const res = (context?.permissions as any)?.resources;
|
||
if (!res) return true; // context بدون مجوز = پزشک در مطب شخصی خودش
|
||
return Boolean(res?.[resource]?.[action]);
|
||
}, [context, primaryRole]);
|
||
|
||
return { can };
|
||
}
|
||
```
|
||
|
||
نکته مهم: نبودِ `permissions` یعنی «مطب شخصی، محدودیتی نیست» — نه «هیچ دسترسی». اگر برعکس پیاده شود، پزشک مستقل کل پنلش را از دست میدهد.
|
||
|
||
سپس `Sidebar.buildSections` (`:52-76`) را از حالت hardcode خارج کن: بهجای «فقط داشبورد و نوبتهای من» برای `scope === 'clinic'`، آیتمها را با `can(resource, 'view')` فیلتر کن. رفتار پیشفرض باید معادل امروز بماند برای مجوز پیشفرضِ محدود، ولی با روشن کردن یک مجوز، آیتم مربوطه ظاهر شود.
|
||
|
||
### ۷. UI مدیریت مجوز در صفحه پزشکان کلینیک
|
||
|
||
- `ClinicDoctorItem` (در `ClinicDoctorsManager.tsx`) فیلد `permissions` و `permission_active` بگیرد (از `doctor-list` برگردانده شود، یا با کوئری جدا).
|
||
- یک `mini-btn` سوم با `ShieldCheckIcon` بین «مشاهده پروفایل» و «جداسازی»، داخل بلوک `{!readOnly && …}`.
|
||
- کامپوننت جدید `assets/admin/components/ui/DoctorPermissionsModal.tsx` — ماتریس چکباکس، عیناً از `SecretariesPage.tsx:82-140` الگو بگیر، با `PERMISSION_LABELS` فارسی برای شش منبع بالا و یک سوییچ «فعال/غیرفعال» برای `active`.
|
||
- ذخیره با `api.patch('/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions', { permissions })` و `invalidateQueries(['clinic-doctors', clinicUuid])`.
|
||
- از `Modal` موجود در `components/ui/` استفاده کن، نه modal دستی. برای هر select احتمالی از `SearchableSelect` استفاده کن، نه `<select>` بومی.
|
||
|
||
### ۸. تست + مستندات
|
||
|
||
تستها در `tests/Clinic/ClinicDoctorPermissionTest.php`:
|
||
- مالک کلینیک مجوز را میخواند و PATCH میکند → ۲۰۰
|
||
- PATCH فقط کلیدهای ارسالی را عوض میکند و بقیه دستنخورده میماند (deep-merge)
|
||
- پزشکِ عضو نمیتواند مجوز خودش را عوض کند → ۴۰۳
|
||
- پزشکِ کلینیک دیگر → ۴۰۴
|
||
- `getOrCreate` برای پزشکی که قبل از feature عضو شده، سطر پیشفرض میسازد
|
||
- context خروجی `/oauth/userinfo` برای پزشکِ عضو، `permissions` دارد و برای مالک ندارد
|
||
|
||
`docs/api/clinic.md` را با دو endpoint جدید، شکل کامل envelope، و جدول کلیدها بهروز کن.
|
||
|
||
## نکات مهم
|
||
|
||
- **سه ضعفِ سیستم منشی را تکرار نکن:** (۱) `SecretaryPermissionChecker` هیچ call site ندارد — چکر جدید باید واقعاً صدا زده شود؛ (۲) `DoctorSecretary::toArray()` envelope را flatten میکند ولی `available_contexts` نمیکند، پس کلاینت دو شکل میبیند — همهجا یک شکل بده؛ (۳) از ۲۲ فلگ منشی فقط ۲ تا واقعاً enforce میشود — کلید بدون enforcement اضافه نکن.
|
||
- همه controllerها از `BaseController` ارث میبرند؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`.
|
||
- تاریخها Unix timestamp صحیح (`time()`), نه `DateTime`.
|
||
- لیستهای admin با DQL array hydration (`->getArrayResult()`).
|
||
- پنل ادمین: paginated → `data?.data` و `data?.meta?.totalRecords`؛ تکآیتم → `data?.data` (ممکن است double-nested باشد).
|
||
- هر تغییر Entity ⇒ `doctrine:migrations:diff` + `migrate`.
|
||
- **مالک کلینیک هرگز نباید بتواند خودش را قفل کند** — چکر برای مالک همیشه `true` برمیگرداند، قبل از هر lookup.
|
||
- edge case: پزشکی که هم مالک کلینیک است هم عضو کلینیک دیگر — `resolvePrimaryRole()` (`AuthController.php:684-691`) یک نقش برنده میدهد، ولی مجوز باید per-context حساب شود نه per-role.
|
||
- edge case: جداسازی پزشک از کلینیک باید سطر `clinic_doctor_permissions` را هم حذف کند (`onDelete: CASCADE` روی FKها این را پوشش نمیدهد چون جدا از `clinic_doctors` است — در endpoint detach صریحاً حذف کن).
|
||
- CSS: از کلاسهای موجود (`btn primary sm`، `mini-btn`، `badge`، `card`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL.
|