Files
clinicpro/.claude/prompt/clinic-doctor-permissions-full-coverage-and-enforcement.md
hamedandClaude Opus 4.8 f33c7a3eab 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>
2026-07-23 19:09:16 +03:30

204 lines
16 KiB
Markdown
Raw Permalink 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.
# پوشش و اعمالِ کاملِ مجوز پزشکِ عضو کلینیک (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 متأثر) به‌روز شود.