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