Files
clinicpro/.claude/prompt/secretary-permissions-full-coverage-phase2.md
T
hamedandClaude Opus 4.8 9d46577181 feat(secretary): add services permission resource + panel gating (phase A)
Secretaries could reach neither the services module (EntityContextResolver
does not recognise a secretary as clinic owner, so they resolved to
`unknown` → 403) nor had any toggle to grant it. Add `services` as a
first-class secretary permission resource, enforced end-to-end.

Backend
- DoctorSecretary::DEFAULT_PERMISSIONS: new `services` resource (default-deny).
- SecretaryAccessChecker::resolveOwnerEntity(): reusable owner (clinic/doctor)
  resolution from the secretary's active context, for controllers whose data
  is fetched by [entityType, entityId] and whose generic resolver is not
  secretary-aware.
- ClinicServiceController: resolveEntity() is now secretary-aware; every action
  (sections, items, tariffs — 13 total) guards with `services` view/create/
  update/delete via denyUnlessGranted, ahead of the subscription gate.

Frontend
- SecretaryPermissions type + MySecretariesPage + SecretariesPage: `services`
  section so owners can grant it.
- Sidebar (secretary branch): services / inventory / tags menu items gated by
  can(resource, 'view').
- RoleRoute: a secretary now needs the page's `permission` to open it (direct
  URL entry included); clinic-services, inventory, tags-settings routes accept
  secretary + permission gate.

Tests
- SecretaryResourceEnforcementTest: services denied-by-default, allowed-when-
  granted, create-denied-while-view-granted.
- Sidebar.test: secretary menu gating for services/inventory/tags.

Docs: secretary.md + clinic-services.md updated with the `services` resource
and the resolveOwnerEntity note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:10:54 +03:30

233 lines
18 KiB
Markdown
Raw 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.
# Secretary Permissions — Full Coverage & Panel Enforcement (Phase 2 / completion)
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک‌پروژه، cross-repo نیست.
> پیش از هر گرِپ/خواندن، طبق قانون پروژه اول `graphify query "..."` بزن. بعد از هر تغییر کد (پس از commit): `graphify update .`.
## زمینه — چه چیزی از قبل انجام شده (تأییدشده در کد)
فاز اول ([secretary-permissions-coverage-and-panel-enforcement.md](secretary-permissions-coverage-and-panel-enforcement.md)) بخش زیادی را ساخته است. **دوباره نساز، فقط شکاف‌ها را کامل کن.**
آنچه **قبلاً هست**:
- **مدل مجوز:** `DoctorSecretary::DEFAULT_PERMISSIONS['resources']` با ۸ منبع:
`appointments, patients, payments, insurances, addresses, clinic_info, inventory, tags`
(`src/Secretary/Entity/DoctorSecretary.php:20`).
- **Enforcement مشترک:** `src/Secretary/Security/SecretaryAccessChecker.php` — نقطهٔ واحد
(`can()`, `canOrNonSecretary()`, `denyUnlessGranted()`). صدا زده می‌شود در:
`PaymentController`, `PatientController`, `InventoryController`, `TenantTagController`,
`InsuranceController`, `PaymentMethodController`, و `AppointmentAccessChecker`.
- **اسکوپ per-doctor نوبت‌ها:** `MyAppointmentsController::resolveSecretaryFilter()` (L316) لیست را
به `doctorIds` مجاز (ردیف‌های `DoctorSecretary`) محدود می‌کند؛ `AppointmentRepository::findByUserAndDoctorIds()`
و `PatientRecordScopeResolver::forSecretary()` (L87) هم بر همان مجموعه پزشکان فیلتر می‌کنند.
- **Frontend gating primitive:** `assets/admin/hooks/usePermissions.ts``can(resource, action)`
(خالی‌بودن `permissions` = محیط شخصی، آزاد). Sidebar از آن استفاده می‌کند.
- **UI مجوزها:** `assets/admin/pages/MySecretariesPage.tsx``PERMISSION_SECTIONS` + `EMPTY_PERMISSIONS`
همان ۸ منبع را نمایش می‌دهد. `SecretaryPermissions` type در `assets/admin/types/index.ts:458`.
## مشکل / هدف — شکاف‌های باقی‌مانده
سه شکاف مشخص، مطابق پلن ۱۰-پرامپتی کاربر:
1. **منابع مجوزِ جانداده‌شده (Coverage gap).** این صفحات/ماژول‌ها منشی به آن‌ها دسترسی دارد یا در
منوی تنظیمات دیده می‌شوند ولی **هیچ toggle مجوز ندارند** و enforce نمی‌شوند:
`services` (سرویس‌ها)، `discounts` (تخفیف‌ها)، `sms` (پیامک‌ها)، `staff` (پرسنل)،
`clinic_doctors` (مدیریت پزشکان کلینیک — فقط حالت کلینیک)، `appointment_settings` (تنظیمات نوبت‌دهی)،
`subscription` (خرید اشتراک/پرداخت‌ها).
2. **اعمال ناقص در Frontend (Panel gap).** `usePermissions().can` فقط برای
`appointments/patients/payments/insurances` در Sidebar استفاده شده
(`assets/admin/components/layout/Sidebar.tsx:365-388`). `inventory`, `tags` و منابع جدید
نه در Sidebar گِیت می‌شوند، نه Route آن‌ها در `App.tsx` گارد دارد، نه دکمه‌های CRUD صفحه پنهان می‌شوند.
3. **حساب کاربری (Account) نباید هیچ مجوزی داشته باشد** — باید همیشه برای منشی باز باشد؛ مطمئن شو
هیچ گارد اشتباهی رویش نیست (Prompt 3).
هدف نهایی: مالک (پزشک مستقل یا کلینیک) دقیقاً تعیین کند هر منشی به کدام صفحه/عملیات دسترسی دارد،
و منشی فقط همان‌ها را در Sidebar/Route/دکمه‌ها ببیند و API هم خارج از مجوز ۴۰۳ بدهد. تست برای **دو حالت:
پزشک مستقل** و **کلینیک چند-پزشکه**.
## فایل‌های مرتبط
| فایل | نقش | کار لازم |
|------|-----|----------|
| `src/Secretary/Entity/DoctorSecretary.php` | `DEFAULT_PERMISSIONS` | افزودن منابع جدید |
| `src/Secretary/Security/SecretaryAccessChecker.php` | نقطهٔ واحد enforcement | بدون تغییر ساختار؛ فقط صدا زدن در کنترلرهای جدید |
| `src/DoctorService/Controller/DoctorServiceController.php` · `src/ClinicService/Controller/ClinicServiceController.php` | سرویس‌ها | گارد `services` |
| `src/Discount/Controller/DiscountController.php` | تخفیف‌ها | گارد `discounts` |
| `src/Sms/Controller/{SmsController,SmsMessageController,SmsWalletController}.php` | پیامک‌ها | گارد `sms` |
| `src/Staff/Controller/StaffController.php` | پرسنل | گارد `staff` |
| `src/Clinic/Controller/ClinicController.php` (بخش پزشکان کلینیک) | مدیریت پزشکان کلینیک | گارد `clinic_doctors` — فقط clinic |
| `src/Appointment/Controller/AppointmentSettingsController.php` | تنظیمات نوبت‌دهی | گارد `appointment_settings` |
| `src/Subscription/Controller/SubscriptionController.php` | اشتراک | گارد `subscription` (یا حذف `ROLE_SECRETARY` اگر نباید ببیند) |
| `assets/admin/pages/MySecretariesPage.tsx` | فرم مجوز | افزودن `PERMISSION_SECTIONS` + `EMPTY_PERMISSIONS` منابع جدید |
| `assets/admin/types/index.ts` (`SecretaryPermissions` L458) | type | افزودن کلیدهای جدید |
| `assets/admin/hooks/usePermissions.ts` | `can()` | بدون تغییر — استفاده گسترده‌تر |
| `assets/admin/components/layout/Sidebar.tsx` (بخش `primaryRole === "secretary"` L361) | منوی منشی | گِیت هر آیتم با `can(resource,'view')` |
| `assets/admin/App.tsx` | جدول Route | گارد Route هر صفحهٔ منشی |
| صفحات پنل هر منبع (Services/Discounts/Sms/Staff/…Page.tsx) | دکمه‌های CRUD | پنهان‌کردن دکمه create/edit/delete با `can()` |
## وضعیت فعلی (کد واقعی)
منبع حقیقت مجوز:
```php
// src/Secretary/Entity/DoctorSecretary.php:20
public const DEFAULT_PERMISSIONS = [
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
'patients' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'clinic_info' => ['view' => true, 'update' => false],
'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
// ← منابع جدید اینجا اضافه می‌شوند
],
];
```
نقطهٔ واحد enforcement (از این الگو در کنترلرهای جدید استفاده کن — چیز جدید نساز):
```php
// src/Secretary/Security/SecretaryAccessChecker.php:73
public function denyUnlessGranted(User $user, string $resource, string $action): void
{
if (!$this->canOrNonSecretary($user, $resource, $action)) {
// → ۴۰۳ استاندارد
}
}
```
Frontend فقط ۴ منبع را در Sidebar گِیت کرده:
```tsx
// assets/admin/components/layout/Sidebar.tsx (primaryRole === "secretary")
if (can("appointments", "view")) { ... }
if (can("patients", "view")) { ... }
if (can("payments", "view")) { ... }
if (can("insurances", "view")) { ... }
// ← inventory / tags / services / discounts / sms / staff / clinic_doctors / appointment_settings گِیت نشده‌اند
```
## وظایف
> قانون هر منبع: **همزمان در هر ۳ جا** اضافه شود وگرنه merge/نمایش می‌شکند —
> (۱) `DoctorSecretary::DEFAULT_PERMISSIONS`، (۲) `MySecretariesPage.tsx` (`EMPTY_PERMISSIONS` + `PERMISSION_SECTIONS`)،
> (۳) `SecretaryPermissions` type. سپس backend enforce + frontend gate.
> بعد از هر تغییر endpoint: `docs/api/*` همان session به‌روز شود. بدون تست (موفق+۴۰۳+مرزی) هیچ تسکی تمام نیست.
### ۰. ممیزی و تصمیم (اول این)
1. `grep -rln ROLE_SECRETARY src/` را با فهرست صفحاتی که منشی در Sidebar/پنل می‌بیند تطبیق بده.
جدول بساز: **منبع | کنترلر(ها) | toggle دارد؟ | backend enforce؟ | sidebar gate؟ | route guard؟ | CRUD gate؟**.
2. برای هر ماژول تصمیم بگیر: **الف)** منشی می‌تواند دسترسی داشته باشد → toggle اضافه کن؛
**ب)** هرگز نباید ببیند → `ROLE_SECRETARY` را از کنترلر بردار و در پرامپت دلیل را بنویس. (مثلاً `subscription`
احتمالاً باید فقط برای owner باشد؛ اگر منشی نباید بخرد، به‌جای toggle نقش را حذف کن.)
### ۱. افزودن منابع مجوز جدید (Prompt 2 + 4 + 9)
برای هر منبعِ زیر که در ممیزی «باید toggle داشته باشد»، در هر ۳ جا اضافه کن:
```php
// DoctorSecretary::DEFAULT_PERMISSIONS['resources'] — همه پیش‌فرض false (اصل least-privilege)
'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'discounts' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'sms' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'staff' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'appointment_settings' => ['view' => false, 'update' => false],
'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], // فقط clinic
```
- `mergePermissions` عمیق merge می‌کند؛ منشی‌های موجود نمی‌شکنند. اما یک data migration اختیاری برای
backfill کلیدهای جدید روی ردیف‌های قدیمی در نظر بگیر (یا در `getPermissions()` با `DEFAULT_PERMISSIONS` ادغام کن).
- `clinic_doctors` فقط در حالت `OWNER_CLINIC` معنا دارد: در `MySecretariesPage.tsx` این section را
فقط وقتی نمایش بده که owner کلینیک است (پزشک مستقل نباید این toggle را ببیند — Prompt 9).
### ۲. Enforcement در Backend (Prompt 6)
در هر کنترلر جدید، **در ابتدای هر اکشن** با نقطهٔ واحد گارد بگذار (چیز جدید نساز):
```php
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); // GET
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create'); // POST
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update'); // PUT/PATCH
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete'); // DELETE
```
- `canOrNonSecretary` تضمین می‌کند برای owner/پزشک/ادمین رفتار تغییر نکند (فقط منشی محدود شود).
- نبود مجوز → همان ۴۰۳ استاندارد `AccessChecker` (نه ۲۰۰ با لیست خالی، نه ۵۰۰).
- کنترلرهای: `DoctorServiceController`/`ClinicServiceController` (`services``DiscountController` (`discounts`
`Sms*Controller` (`sms``StaffController` (`staff``AppointmentSettingsController` (`appointment_settings`
بخش پزشکانِ `ClinicController` (`clinic_doctors`).
### ۳. Enforcement در Frontend — Sidebar + Route + CRUD (Prompt 5 + 7)
1. **Sidebar** (`Sidebar.tsx`، بخش `secretary`): هر آیتم منو را با `can(resource,'view')` بپیچ —
شامل `inventory`, `tags` و همهٔ منابع جدید. منشی نباید هیچ منوی بدون‌مجوز ببیند و نباید هیچ منوی اضافی بماند.
2. **Route guard** (`App.tsx`): هر Route صفحهٔ منشی را گارد کن؛ ورود مستقیم با URL بدون مجوز →
redirect یا صفحهٔ «دسترسی ندارید» فارسی استاندارد (نه صفحهٔ سفید). اگر helper مشترکی نیست،
یک `<RequirePermission resource action>` سبک بساز که از `usePermissions().can` استفاده کند
(از `FeatureGate` موجود الگو بگیر).
3. **CRUD/Action buttons** در هر صفحه: دکمه‌های create/edit/delete/import/export و
FloatingAction/ContextMenu/QuickAction را با `can(resource, action)` شرطی کن. نبودِ مجوز = پنهان، نه disable.
4. Breadcrumb صفحهٔ بدون‌مجوز هم نباید ساخته شود.
### ۴. حساب کاربری بدون مجوز (Prompt 3)
- صفحهٔ Account/پروفایل کاربری باید برای منشی همیشه باز باشد. بررسی کن هیچ `ROLE_*` سخت‌گیرانه یا
`denyUnlessGranted` اشتباه رویش نیست و هیچ toggle برایش تعریف نشده. اگر endpoint اکانت
`IS_AUTHENTICATED_FULLY` دارد کافی است — دست نزن.
### ۵. اسکوپ per-doctor نوبت‌ها — تکمیل پوشش عملیات (Prompt 8)
اسکوپ لیست از قبل هست (`resolveSecretaryFilter`)؛ فقط مطمئن شو **همهٔ** عملیات نوبت هم به مجموعهٔ
پزشکان مجاز محدودند، نه فقط GET لیست:
- create / update / cancel / update_status / قطعی‌کردن / پرداخت / فاکتور / تغییر وضعیت / مشاهدهٔ پروندهٔ بیمار.
- در هر اکشن، پیش از عمل چک کن `doctor` هدف در `allowed doctorIds` منشی باشد (از `DoctorSecretaryRepository`
در غیر این صورت ۴۰۳ — حتی اگر `appointments.<action>=true`. (مجوز action و اسکوپ doctor **هر دو** لازم‌اند.)
- تست هر دو سناریو: پزشک مستقل (فقط نوبت‌های خودش) و کلینیک با منشیِ محدود به زیرمجموعه‌ای از پزشکان.
### ۶. تست نهایی و ماتریس (Prompt 10)
- Backend (`ddev exec php bin/phpunit`): برای هر منبع تازه، تست منشی مجاز (۲۰۰) و غیرمجاز (۴۰۳)،
و برای نوبت‌ها تست دسترسی به پزشک خارج از اسکوپ (۴۰۳).
- Frontend (`yarn test`): Sidebar با permissionهای مختلف فقط آیتم‌های مجاز را رندر کند؛
دکمه‌های CRUD بدون مجوز رندر نشوند.
- دستی با منشیِ واقعی (`clinicpro-QA accounts`) در دو حالت پزشک مستقل و کلینیک.
- جدول نهایی را در خروجی بده و فقط وقتی «تمام» اعلام کن که همه PASS باشند:
```
Resource | Toggle | Sidebar | Route | API(403) | CRUD | Tested
appointments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
patients | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
payments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
insurances | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
addresses | ✓ | … | … | ✓ | … | ?
clinic_info | ✓ | … | … | ✓ | … | ?
inventory | ✓ | ? | ? | ✓ | ? | ?
tags | ✓ | ? | ? | ✓ | ? | ?
services | + | + | + | + | + | ?
discounts | + | + | + | + | + | ?
sms | + | + | + | + | + | ?
staff | + | + | + | + | + | ?
appointment_settings | + | + | + | + | + | ?
clinic_doctors(clinic)| + | + | + | + | + | ?
```
(`+` = این پرامپت باید بسازد، `…`/`?` = ممیزی وظیفهٔ ۰ وضعیت واقعی را پر کند.)
## نکات مهم
- **منبع حقیقت = `DoctorSecretary.permissions` JSON.** enforcement جدید همین را بخواند، نه
`ClinicDoctorPermissionChecker` (که مال پزشکِ عضو کلینیک است، نه منشی).
- نقطهٔ واحد enforcement از قبل هست: `SecretaryAccessChecker`. **کنترلر/checker موازی نساز** — فقط `denyUnlessGranted` صدا بزن (اصل ۲ CLAUDE.md: اول موجود را استفاده کن).
- هر منبع همزمان در سه‌گانهٔ Entity default / UI section / TS type اضافه شود.
- `clinic_doctors` فقط حالت کلینیک؛ برای پزشک مستقل نه toggle نه منو.
- least-privilege: منابع جدید پیش‌فرض همه `false`.
- تاریخ‌ها Unix timestamp؛ پاسخ‌ها `$this->success()/$this->error()`؛ لیست‌ها array-hydration.
- رشته‌های UI فارسی، RTL، شمسی. کد/کامیت/مستندات انگلیسی؛ گفت‌وگو فارسی. اول spec انگلیسی، تأیید فارسی، بعد پیاده‌سازی.
- بعد از تغییر endpointها، این فایل‌های `docs/api/` به‌روز: `secretary.md`, `service.md`/`clinic-service.md`, `discount.md`, `sms.md`, `staff.md`, `clinic.md`, `appointment.md`, `subscription.md`.