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

16 KiB

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

وضعیت فعلی (کد واقعی)

منبع حقیقتِ مجوز پزشک عضو:

// 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);

سایدبارِ پزشک مهمان فقط ۲ منبع را گِیت می‌کند:

// 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 هم می‌تواند ویرایش کند):

// src/ClinicService/Controller/ClinicServiceController.php
// هیچ ClinicDoctorPermissionChecker صدا زده نمی‌شود؛ فقط SecretaryAccessChecker (مخصوص منشی).
// resolveEntity برای پزشکِ عضو، کلینیک را برمی‌گرداند و بدون هیچ گِیتی اجازهٔ نوشتن می‌دهد.

بیمه با checkerِ اشتباه:

// 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).

// نمونه افزودن به 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 متأثر) به‌روز شود.