feat: add per-doctor permissions management in clinics

- 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.
This commit is contained in:
hamed
2026-07-18 09:44:13 +03:30
parent 3a23aa242e
commit e4ddd38f0c
17 changed files with 1504 additions and 44 deletions
+241
View File
@@ -0,0 +1,241 @@
# مدیریت دسترسی پزشکان کلینیک (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.