feat(clinic-doctor): full permission coverage + enforcement, parity with secretary

The clinic-member-doctor permission system (ClinicDoctorPermission) lagged the
secretary system: only 6 resources, enforced in ~6 places, dead toggles
(services.update never checked), and a sidebar showing just appointments+patients.
Bring it to parity so a clinic owner can control exactly what each member doctor
does — while an independent doctor stays completely unrestricted.

Coverage: add insurances, addresses, inventory, tags, staff, discounts, sms to
ClinicDoctorPermission::DEFAULT_PERMISSIONS + DoctorPermissionsModal
(subscription/clinic_doctors stay owner-only by design).

New App\Clinic\Security\ClinicDoctorAccessChecker (parallel to
SecretaryAccessChecker):
- denyUnlessGranted(user, resource, action): 403 only for a clinic-member doctor
  in the clinic context; owner/admin/secretary/independent-doctor pass through.
- memberClinicId(user): resolves the member doctor to the CLINIC's tenant so the
  role-based controllers (Inventory/Tag/Staff/Discount/Sms) stop showing them
  their personal tenant in clinic context.

Enforcement wired into 10 controllers alongside the existing secretary gates:
ClinicService (services), Insurance (insurances), Patient (patients+payments),
Staff, Discount, Inventory, Tag, SmsWallet, Payment, PaymentMethod.

Frontend: the guest-doctor sidebar branch now exposes every permitted resource
(gated by can()) plus a «تنظیمات» entry; both settings navs (PurchaseSubscription
Sidebar + SETTINGS_MENU) are now permission-filtered for a scope=clinic doctor,
not just secretaries; my-payments route gets the missing payments permission.
CRUD-button gating already applies (usePermissions is role-agnostic).

Tests: ClinicDoctorPermissionEnforcementTest (member denied/allowed +
independent-doctor-unrestricted); guest-doctor sidebar gating. Backend 375 pass,
frontend 503 pass. docs/api/clinic.md updated with the full resource set +
enforcement notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-23 19:09:16 +03:30
co-authored by Claude Opus 4.8
parent 1116ac14cb
commit f33c7a3eab
21 changed files with 617 additions and 31 deletions
@@ -0,0 +1,203 @@
# پوشش و اعمالِ کاملِ مجوز پزشکِ عضو کلینیک (ClinicDoctorPermission) — همتای کار منشی
## زمینه
برای منشی (`DoctorSecretary.permission`) یک سیستم مجوزِ کامل ساختیم: ۱۵ منبع، enforcement در بک‌اند
(`SecretaryAccessChecker::denyUnlessGranted`)، گِیت سایدبار/Route با `usePermissions().can`، و گِیتِ
دکمه‌های CRUD در همهٔ صفحات. سیستمِ **موازی** برای «پزشکی که به کلینیک اضافه می‌شود» (پزشکِ مهمانِ
عضو کلینیک) `ClinicDoctorPermission` است — اما ناقص مانده و با کار منشی هم‌تراز نیست:
- فقط **۶ منبع** دارد: `appointments`, `appointment_settings`, `patients`, `payments`, `services`, `clinic_info`
(`src/Clinic/Entity/ClinicDoctorPermission.php:21`) در برابر ۱۵ منبعِ منشی.
- enforcement فقط در **۶ نقطه** صدا زده می‌شود: `AppointmentAccessChecker`, `AppointmentSettingsController`,
`DashboardController`, `PatientRecordScopeResolver`, `ClinicController`, `InsuranceController`.
- سایدبارِ پزشکِ مهمان فقط `appointments` و `patients` را نشان می‌دهد؛ بقیهٔ منابع (حتی `services`/
`appointment_settings` که toggle دارند) در منو نیستند.
- چند toggle **مرده‌اند**: `services.update` هیچ‌جا enforce نمی‌شود، و بیمه با checkerِ منشی گِیت شده نه
ClinicDoctorPermission.
هدف: `ClinicDoctorPermission` را به همان کاملیِ سیستم منشی برسانیم — برای **دو حالت** «پزشک مستقل»
(بدون context کلینیک، آزاد) و «پزشک عضو کلینیک» (`scope=clinic`، محدود به مجوزهای کلینیک).
> پیش از هر گرِپ/خواندن: `graphify query "..."`. بعد از هر تغییر کد (پس از commit): `graphify update .`.
## مشکل / هدف
سه شکاف، مطابق همان الگوی منشی:
1. **پوشش (Coverage):** هر صفحه/عملیاتی که پزشکِ عضو کلینیک به آن می‌رسد باید toggle مجوز داشته باشد و
در فرم «مدیریت دسترسی‌های پزشک» نمایش داده شود. حداقل بررسی: `insurances`, `inventory`, `tags`,
`staff`, `discounts`, `sms` — هرکدام که پزشک عضو باید کنترل شود.
2. **اعمال (Enforcement):** هر منبع باید در نقطهٔ درستِ بک‌اند با `ClinicDoctorPermissionChecker::can(
$user, $clinic, $resource, $action)` گِیت شود (۴۰۳ اگر مجوز نبود)، نه فقط توگل ذخیره‌شود. توگل‌های
مردهٔ فعلی (`services.update`، `payments.*`، …) واقعاً enforce شوند.
3. **پنل (Frontend):** سایدبارِ پزشکِ مهمان همهٔ منابعِ مجاز را با `can()` نشان دهد؛ Route‌ها با
`blockClinicScope` + `permission` گِیت شوند؛ دکمه‌های CRUD طبق مجوز پنهان شوند (بخش زیادی از این با
sweepِ منشی که role-agnostic بود از قبل کار می‌کند — فقط تأیید و تکمیل).
## فایل‌های مرتبط
| فایل | نقش | کار |
|------|-----|-----|
| `src/Clinic/Entity/ClinicDoctorPermission.php` | `DEFAULT_PERMISSIONS` + `mergePermissions` (valid-resource whitelist) | افزودن منابع جدید |
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | checker (`can($user,$clinic,$resource,$action)`) | نقطهٔ واحد enforcement پزشک عضو |
| `src/ClinicService/Controller/ClinicServiceController.php` | خدمات — **هیچ چک ClinicDoctorPermission ندارد** | گارد `services` برای پزشک عضو |
| `src/Insurance/Controller/InsuranceController.php` | نوشتن‌ها با `secretaryAccess` گِیت شده، نه پزشک عضو | گارد `insurances` برای پزشک عضو |
| `src/Inventory/Controller/InventoryController.php` · `src/Tag/Controller/TenantTagController.php` · `src/Staff/Controller/StaffController.php` · `src/Discount/Controller/DiscountController.php` · `src/Sms/Controller/SmsWalletController.php` | فقط `secretaryAccess` دارند | گارد پزشک عضو اگر باید کنترل شود |
| `src/Patient/Controller/PatientController.php` · `PatientRecordScopeResolver.php` | scope + نوشتن‌ها | تأیید گارد `patients` پزشک عضو |
| `assets/admin/components/ui/DoctorPermissionsModal.tsx` | `RESOURCE_LABELS` (۶ منبع) | افزودن منابع جدید |
| `assets/admin/components/layout/Sidebar.tsx` (بلوک `primaryRole === "doctor" && scope === "clinic"`, L71) | منوی پزشک مهمان — فقط appointments/patients | افزودن بقیهٔ منابع با `can()` |
| `assets/admin/App.tsx` | Route‌ها با `blockClinicScope` + `permission` | تأیید/تکمیل گِیت پزشک مهمان |
| `src/Auth/Controller/AuthController.php` (`contextPermissions`, L~778؛ `buildAvailableContexts`) | تزریق permissions پزشک مهمان به context | بدون تغییر ساختار |
| `docs/api/clinic.md` | مستندات مجوز پزشک کلینیک | به‌روزرسانی |
## وضعیت فعلی (کد واقعی)
منبع حقیقتِ مجوز پزشک عضو:
```php
// src/Clinic/Entity/ClinicDoctorPermission.php:21
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 فقط منابعِ داخل DEFAULT_PERMISSIONS را می‌پذیرد (منبع ناشناس رد می‌شود):
// L98: if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) { continue; }
// can(): L88: return (bool) ($this->permissions['resources'][$resource][$action] ?? false);
```
سایدبارِ پزشک مهمان فقط ۲ منبع را گِیت می‌کند:
```tsx
// assets/admin/components/layout/Sidebar.tsx — بلوک doctor + scope=clinic (L71)
if (primaryRole === "doctor" && scope === "clinic") {
const items = [{ to: "/admin/dashboard", ... }];
if (can("appointments", "view")) { items.push({ to: "/admin/appointments", ... }); }
if (can("patients", "view")) { items.push({ to: "/admin/patients", ... }); }
return [{ label: "عمومی", items }]; // ← services / appointment_settings / payments / … نیستند
}
```
توگل مردهٔ services (پزشک عضو با `services.update=false` هم می‌تواند ویرایش کند):
```php
// src/ClinicService/Controller/ClinicServiceController.php
// هیچ ClinicDoctorPermissionChecker صدا زده نمی‌شود؛ فقط SecretaryAccessChecker (مخصوص منشی).
// resolveEntity برای پزشکِ عضو، کلینیک را برمی‌گرداند و بدون هیچ گِیتی اجازهٔ نوشتن می‌دهد.
```
بیمه با checkerِ اشتباه:
```php
// src/Insurance/Controller/InsuranceController.php:307
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update'); // ← فقط منشی؛
// برای پزشک عضو canOrNonSecretary → true → گِیت نمی‌شود. باید permChecker->can($user,$clinic,'insurances',$action) هم باشد.
// (خط ۷۶ همین کنترلر از permChecker با منبع 'services' استفاده می‌کند — الگوی درست همان‌جاست.)
```
## وظایف
> هر منبع همزمان در سه‌گانه اضافه شود وگرنه merge/نمایش می‌شکند:
> (۱) `ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources']
> (۲) `DoctorPermissionsModal.tsx::RESOURCE_LABELS
> (۳) هر جای دیگری که منابع را فهرست می‌کند (contextPermissions خودکار از entity می‌خواند، دستی نیست).
> بعد از تغییر endpoint: `docs/api/*` همان session. بدون تست (موفق+۴۰۳+مرزی) هیچ تسکی تمام نیست.
### ۰. ممیزی و تصمیم (اول این)
1. برای هر منبعی که سیستم منشی دارد ولی ClinicDoctorPermission ندارد
(`insurances, inventory, tags, staff, discounts, sms`)، مشخص کن آیا پزشکِ عضو کلینیک به آن صفحه
دسترسی دارد (از سایدبار/Route/endpoint). جدول بساز:
**منبع | پزشک عضو می‌رسد؟ | toggle در ClinicDoctorPermission؟ | enforce با permChecker؟ | sidebar؟ | Route؟**.
2. تصمیم بگیر کدام‌ها باید toggle بگیرند (مثلاً بیمه/انبار/تگ/خدمات منطقی‌اند؛ خرید اشتراک و مدیریت
پزشکان کلینیک ذاتاً مالک‌اند و برای پزشکِ عضو نباید باشند). دلیل هر تصمیم را در پرامپت بنویس.
### ۱. افزودن منابع مجوز (Coverage)
برای هر منبعِ تصمیم‌گرفته‌شده، در `DEFAULT_PERMISSIONS['resources']` و `RESOURCE_LABELS` اضافه کن.
پیش‌فرضِ منطقی برای پزشکِ عضو: `view=true` و نوشتن‌ها `false` (مثل الگوی فعلی `services`/`clinic_info`).
```php
// نمونه افزودن به ClinicDoctorPermission::DEFAULT_PERMISSIONS
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'inventory' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'tags' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
// ... طبق تصمیم وظیفهٔ ۰
```
> چون `mergePermissions` whitelist دارد، منابعِ جدید حتماً باید در `DEFAULT_PERMISSIONS` باشند تا از فرم
> ذخیره شوند. `contextPermissions` خودکار از entity می‌خواند؛ نیازی به تغییر دستی نیست.
### ۲. اعمال در بک‌اند (Enforcement)
الگو را از `InsuranceController.php:76` بگیر (`$this->permChecker->can($user, $clinic, $resource, $action)`).
برای مسیرهای چند-نقشه، فقط پزشکِ عضو کلینیک را محدود کن (سایر نقش‌ها بدون تغییر). نکات:
- **services:** در `ClinicServiceController` برای هر اکشن، اگر کاربر پزشکِ عضو کلینیک است، با
`permChecker->can($user, $clinic, 'services', $action)` گِیت کن (view/update). کلینیک را از محیطِ فعال
حل کن (همان `EntityContextResolver` که الان استفاده می‌شود کلینیک را می‌دهد؛ فقط چکِ مجوز اضافه کن).
- **insurances:** در `InsuranceController` نوشتن‌ها، علاوه‌بر `secretaryAccess`، `permChecker->can(...,'insurances',$action)`
را هم برای پزشکِ عضو اعمال کن (اگر `insurances` toggle گرفت).
- **payments / patients:** تأیید کن نوشتن‌های پزشکِ عضو با `permChecker` گِیت می‌شوند (patients scope از
`PatientRecordScopeResolver` می‌آید؛ نوشتن‌ها گارد جدا لازم دارند).
- **inventory/tags/staff/discounts/sms:** اگر toggle گرفتند، در همان کنترلرها برای پزشکِ عضو گارد بگذار
(این کنترلرها الان فقط `secretaryAccess` دارند).
- نبودِ مجوز → `403 ERR_FORBIDDEN_001` استاندارد (مثل `SecretaryAccessChecker`).
> **SOLID:** اگر منطقِ «آیا این کاربر پزشکِ عضوِ همین کلینیک است و مجوز دارد؟» در چند کنترلر تکرار شد،
> یک متدِ کمکی روی `ClinicDoctorPermissionChecker` بساز (قرینهٔ `SecretaryAccessChecker::denyUnlessGranted`)
> تا تکرار نشود. الگوی موجود منشی را دنبال کن.
### ۳. اعمال در پنل (Frontend)
1. **Sidebar** (`Sidebar.tsx`، بلوک `doctor && scope=clinic`, L71): برای هر منبعِ مجاز آیتم منو اضافه کن،
هرکدام با `can(resource,'view')` — services، تنظیمات نوبت‌دهی، پرداخت‌ها، بیمه، انبار، تگ، … دقیقاً
مثل بلوکِ `secretary`. منوی مطبِ شخصیِ پزشکِ مستقل (`primaryRole==='doctor'` بدون scope کلینیک) نباید
تغییر کند — آنجا مالک است و آزاد.
2. **Route** (`App.tsx`): مطمئن شو هر صفحهٔ پزشکِ عضو با `blockClinicScope permission={[resource,'view']}`
گِیت شده (این الگو از قبل برای پزشکِ مهمان طراحی شده؛ فقط منابعِ جدید را پوشش بده).
3. **CRUD buttons:** از قبل با `usePermissions().can` گِیت شده‌اند (sweepِ منشی role-agnostic بود، پس
برای پزشکِ عضو هم کار می‌کند). فقط تأیید کن صفحاتِ منابعِ جدید هم پوشش دارند.
### ۴. تست‌ها و ماتریس نهایی
- بک‌اند (`ddev exec php bin/phpunit`): برای هر منبع، پزشکِ عضوِ مجاز (۲۰۰) و غیرمجاز (۴۰۳)؛ و تأیید
اینکه **پزشک مستقل** (بدون context کلینیک) هیچ محدودیتی نمی‌گیرد. یک `ClinicDoctorPermissionEnforcementTest`
قرینهٔ `SecretaryResourceEnforcementTest` بساز.
- فرانت‌اند (`yarn test` روی host با `npx vitest`): سایدبارِ پزشکِ مهمان با مجوزهای مختلف فقط آیتم‌های
مجاز را رندر کند.
- جدول نهایی مثل کار منشی:
```
Resource | Toggle | Sidebar | Route | API(403) | CRUD | Tested
appointments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
appointment_settings | ✓ | + | ✓ | ✓ | ✓ | ?
patients | ✓ | ✓ | ✓ | ? | ✓ | ?
payments | ✓ | + | + | + | ✓ | ?
services | ✓ | + | + | + | ✓ | ?
clinic_info | ✓ | + | + | + | ✓ | ?
insurances | + | + | + | + | ✓ | ?
inventory/tags/… | + | + | + | + | ✓ | ?
```
(`+` = این پرامپت باید بسازد/تکمیل کند.)
## نکات مهم
- **منبع حقیقتِ پزشکِ عضو = `ClinicDoctorPermission.permissions`** (نه `DoctorSecretary` که مالِ منشی است).
هر دو سیستم جدا هستند و نباید قاطی شوند.
- **پزشکِ مستقل مطلقاً محدود نشود:** `usePermissions().can` بدون context آزاد است؛ در بک‌اند هم گِیت فقط
وقتی اعمال شود که کاربر پزشکِ عضوِ همان کلینیک باشد (`context.scope==='clinic'` / کلینیک از محیطِ فعال).
- `mergePermissions` whitelist دارد → منبع جدید حتماً در `DEFAULT_PERMISSIONS`.
- الگوی enforcement را از `InsuranceController::76` و کارِ منشی (`SecretaryAccessChecker`) بگیر؛ SOLID،
بدون تکرار.
- تاریخ‌ها Unix timestamp؛ پاسخ‌ها `$this->success()/$this->error()`؛ لیست‌ها array-hydration.
- رشته‌های UI فارسی، RTL، شمسی. کد/کامیت/مستندات انگلیسی؛ گفت‌وگو فارسی. اول spec انگلیسی، تأیید فارسی، بعد پیاده‌سازی.
- بعد از تغییر endpoint‌ها: `docs/api/clinic.md` (+ هر domain متأثر) به‌روز شود.