# مدیریت دسترسی پزشکان کلینیک (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.
