diff --git a/.claude/prompt/dynamic-permission-registry.md b/.claude/prompt/dynamic-permission-registry.md new file mode 100644 index 00000000..f0988bbc --- /dev/null +++ b/.claude/prompt/dynamic-permission-registry.md @@ -0,0 +1,366 @@ +# رجیستری واحدِ مجوزها برای منشی و پزشکِ دعوت‌شده + +## پروژه + +`clinicpro` (backend + پنل ادمین). cross-repo نیست. + +## زمینه + +دو نقش در یک کلینیک مجوز per-resource دارند: **منشی** و **پزشکِ دعوت‌شده**. هر کدام +جدول مجوز خودش را دارد و هر کدام یک فهرست جدا از «منابع» در کد. + +امروز آن فهرست **چهار بار** نوشته شده و هیچ‌کدام از هم خبر ندارند: + +| کجا | چه چیزی | +|---|---| +| `src/Secretary/Entity/DoctorSecretary.php:26` | `DEFAULT_PERMISSIONS` — ۱۵ منبع | +| `src/Clinic/Entity/ClinicDoctorPermission.php:21` | `DEFAULT_PERMISSIONS` — ۱۳ منبع | +| `assets/admin/pages/MySecretariesPage.tsx:70` | `EMPTY_PERMISSIONS` + `PERMISSION_SECTIONS` | +| `assets/admin/components/ui/DoctorPermissionsModal.tsx:24` | `RESOURCE_LABELS` | + +نتیجه‌اش را می‌شود در خودِ کد دید. + +## مشکل / هدف + +**۱. دو فهرست backend با هم فرق دارند.** + +منشی این دو را دارد و پزشک ندارد: `clinic_doctors`، `subscription`. +و برای `services`: + +```php +// DoctorSecretary +'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], +// ClinicDoctorPermission +'services' => ['view' => true, 'update' => false], +``` + +یعنی همان منبع در دو نقش دو مجموعهٔ action دارد. `ClinicDoctorPermission::apply()` +هر actionی را که در `DEFAULT_PERMISSIONS` نباشد دور می‌اندازد، پس `create` و `delete` +برای پزشک اصلاً قابل ذخیره نیست. + +**۲. صفحه‌های تازه، مجوزِ صفحهٔ دیگری را قرض می‌گیرند.** + +از ۸۴ route، ۳۲ تا گیت مجوز دارند. ولی پنج صفحهٔ متفاوت روی **یک** منبع نشسته‌اند: + +``` +resources · resource-types · catalog-categories · skills · resource-pools + → همه: permission={['appointment_settings', 'view']} +treatment-cases + → permission={['appointments', 'view']} +``` + +هیچ‌کدام از این‌ها واقعاً «تنظیمات نوبت‌دهی» یا «نوبت‌ها» نیستند. دلیلش روشن است: +افزودن یک منبع تازه یعنی ویرایش دستیِ چهار فهرست، پس هرکس نزدیک‌ترین منبع موجود را +برداشته. مجوزها از ساختار صفحه‌ها جدا افتاده‌اند. + +**۳. جواب سؤال «آیا می‌شود داینامیک باشد؟» بله است.** + +یک رجیستری در PHP به‌عنوان منبع واحد، یک اندپوینت که آن را می‌دهد، و دو UI که به‌جای +فهرست هاردکد از همان می‌خوانند. صفحهٔ تازه = یک ردیف در رجیستری، نه چهار ویرایش. + +--- + +## ⚠ دو تصمیم که باید قبل از کد روشن باشد + +### الف) «دقیقاً شبیه پزشک» یعنی فهرست یکی شود، نه پیش‌فرض‌ها + +**فهرستِ منابع و actionها** برای هر دو نقش یکی می‌شود — این خواستهٔ روشنِ کاربر است. + +**مقادیر پیش‌فرض** یکی نمی‌شوند و نباید بشوند: پزشکِ عضو کلینیک به‌طور طبیعی از منشی +دسترسی بیشتری دارد (مثلاً `patients.update` پیش‌فرضش `true` است و برای منشی `false`). +یکی کردنشان یعنی یا منشی بیش از حد باز شود یا پزشک بی‌جهت بسته. + +پس رجیستری **شکل** را می‌دهد و هر نقش **پیش‌فرضِ خودش** را. + +اگر منظور کاربر این نبوده و واقعاً می‌خواهد پیش‌فرض‌ها هم یکی شود، قبل از پیاده‌سازی +بپرس — این تغییر روی همهٔ منشی‌های موجود اثر می‌گذارد. + +### ب) دادهٔ ذخیره‌شده نباید پاک شود + +هر دو جدول ستون `permission` از نوع `json` دارند و مقدارِ فعلیِ منشی‌ها و پزشک‌ها +داخلش است. افزودن منبع تازه به رجیستری **نباید** مقدار ذخیره‌شده را بازنویسی کند. + +قاعده: خواندن = merge رجیستری با مقدارِ ذخیره‌شده؛ کلیدِ نبوده از پیش‌فرضِ رجیستری +پر می‌شود. **migration دادهٔ انبوه لازم نیست** و نباید نوشته شود. + +--- + +## معیار پذیرش + +- ✅ موفق: `GET /api/v1/permission-catalog` با توکن مالک کلینیک → ۲۰۰ و فهرست همهٔ + منابع با actionها و برچسب فارسی. صفحهٔ `/admin/my-secretaries` و مودال مجوز پزشک + هر دو از همین پاسخ رندر می‌شوند و هیچ فهرست هاردکدی ندارند. +- ✅ موفق: افزودن یک منبع تازه به رجیستری (مثلاً `treatment`) → بدون هیچ تغییر دیگری + در فرانت، هم در فرم منشی و هم در مودال پزشک ظاهر می‌شود. +- ✅ موفق: منشیِ بدون `treatment.view` وارد `/admin/treatment-cases` شود → به داشبورد + برگردانده شود (`RoleRoute`)، و `GET /api/v1/treatment-cases` برایش ۴۰۳ بدهد. +- ❌ خطا: `PUT` مجوز با منبعِ ناشناخته یا actionِ ناشناخته → ۴۲۲، و مقدار قبلی + دست‌نخورده بماند. +- ⚠️ مرزی: منشیِ ساخته‌شده **قبل** از این تغییر که `treatment` در JSONش نیست → + خواندنش خطا ندهد و آن منبع با پیش‌فرضِ رجیستری برگردد، نه `null`. +- ⚠️ مرزی: مالک کلینیک و ادمین همیشه مجازند — رجیستری نباید این را عوض کند + (`ClinicDoctorPermissionChecker::can()` خط ۲۶). +- ⚠️ مرزی: محیط شخصیِ پزشک `permissions` ندارد؛ `usePermissions` نبودش را «محدودیتی + نیست» می‌خواند و این باید همان بماند. + +--- + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Secretary/Entity/DoctorSecretary.php` | `DEFAULT_PERMISSIONS` منشی | +| `src/Clinic/Entity/ClinicDoctorPermission.php` | `DEFAULT_PERMISSIONS` پزشک + `apply()` که actionِ ناشناخته را دور می‌ریزد | +| `src/Secretary/Security/SecretaryPermissionChecker.php` | بررسی مجوز منشی | +| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | بررسی مجوز پزشک | +| `assets/admin/pages/MySecretariesPage.tsx` | فرم مجوز منشی | +| `assets/admin/components/ui/DoctorPermissionsModal.tsx` | مودال مجوز پزشک | +| `assets/admin/hooks/usePermissions.ts` | `can(resource, action)` در فرانت | +| `assets/admin/App.tsx` | گیتِ route با `permission={[resource, action]}` | +| `docs/api/secretary.md` · `docs/api/clinic.md` | سند اندپوینت‌ها | + +--- + +## وضعیت فعلی + +### رجیستری منشی + +```php +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], + // … + 'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], + 'subscription' => ['view' => false, 'create' => false], + ], +]; +``` + +### رجیستری پزشک — دو منبع کمتر، و `services` با actionهای متفاوت + +```php +public const DEFAULT_PERMISSIONS = [ + 'version' => 1, + 'resources' => [ + 'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true], + 'services' => ['view' => true, 'update' => false], + // clinic_doctors و subscription اصلاً نیستند + ], +]; +``` + +### فرانت — همان فهرست، بار سوم و چهارم + +```tsx +// DoctorPermissionsModal.tsx +const RESOURCE_LABELS: Record }> = { + appointments: { + label: 'نوبت‌ها', + actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' }, + }, + // … +}; +``` + +```tsx +// MySecretariesPage.tsx +const EMPTY_PERMISSIONS: SecretaryPermissions = { + appointments: { view: false, create: false, cancel: false, update_status: false }, + // … +}; +``` + +### گیتِ فعلیِ صفحه‌های تازه + +```tsx +} /> +} /> +``` + +--- + +## وظایف + +### ۱. رجیستری منابع — منبع واحد + +`src/Shared/Security/PermissionCatalog.php` + +یک کلاس با یک ثابت: هر منبع، actionهایش، و برچسب فارسی. **هیچ پیش‌فرضی اینجا نیست** — +اینجا فقط «چه چیزهایی وجود دارند». + +```php +final class PermissionCatalog +{ + /** @var array}> */ + public const RESOURCES = [ + 'appointments' => [ + 'label' => 'نوبت‌ها', + 'actions' => ['view' => 'مشاهده', 'create' => 'ایجاد', 'cancel' => 'لغو', 'update_status' => 'تغییر وضعیت'], + ], + // … + 'resources' => [ + 'label' => 'منابع و دستگاه‌ها', + 'actions' => ['view' => 'مشاهده', 'create' => 'ایجاد', 'update' => 'ویرایش', 'delete' => 'حذف'], + ], + 'treatment' => [ + 'label' => 'درمان‌های چندجلسه‌ای', + 'actions' => ['view' => 'مشاهده', 'update' => 'ویرایش'], + ], + ]; + + /** شکلِ خالی — برای merge با مقدارِ ذخیره‌شده. */ + public static function blank(): array { /* همهٔ actionها false */ } + + /** مقدارِ ذخیره‌شده + کلیدهای نبوده از پیش‌فرضِ نقش. */ + public static function merge(array $stored, array $roleDefaults): array { /* … */ } +} +``` + +**دو منبع تازه که باید اضافه شوند** (چون صفحه‌شان امروز مجوزِ قرضی دارد): +`resources` برای پنج صفحهٔ منابع، و `treatment` برای `treatment-cases`. + +**الگو: Registry/Catalog.** دلیل: چهار فهرستِ موازی که دستی هم‌گام می‌شوند، دیر یا زود +واگرا می‌شوند — و همین حالا شده‌اند. یک ثابت که هر چهار مصرف‌کننده از آن می‌خوانند، +واگرایی را از نظر ساختاری غیرممکن می‌کند. + +**نحوه تست:** تست واحد در `tests/Shared/` — `blank()` باید برای هر منبعِ رجیستری کلید +بدهد؛ `merge()` با یک `stored` که یک منبع کم دارد باید آن را از پیش‌فرضِ نقش پر کند و +مقادیرِ موجود را **دست نزند**. + +--- + +### ۲. هر دو Entity از رجیستری بخوانند + +`DEFAULT_PERMISSIONS` هر دو کلاس می‌ماند ولی معنی‌اش عوض می‌شود: فقط **پیش‌فرضِ نقش**، +نه تعریفِ ساختار. اعتبارسنجیِ `apply()` باید به `PermissionCatalog::RESOURCES` نگاه کند +نه به `self::DEFAULT_PERMISSIONS`. + +```php +// ClinicDoctorPermission::apply() — امروز: +if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) { continue; } +// باید بشود: +if (!is_array($actions) || !isset(PermissionCatalog::RESOURCES[$resource])) { continue; } +``` + +با این تغییر، `services.create` برای پزشک هم قابل ذخیره می‌شود — چون رجیستری واحد +است. همین «شبیه شدنِ منشی و پزشک» است. + +**نحوه تست:** تست موجودِ مجوز پزشک را اجرا کن، بعد یک تست تازه: ذخیرهٔ +`services.create = true` برای پزشک، خواندن دوباره، و بررسی اینکه مانده — امروز دور +ریخته می‌شود. + +--- + +### ۳. اندپوینت کاتالوگ + +`GET /api/v1/permission-catalog` + +**Permission:** `IS_AUTHENTICATED_FULLY`. پاسخ ثابت است و به کاربر بستگی ندارد، پس +جای مناسبی برای cache سمت کلاینت است (`staleTime` بلند). + +```json +{ "success": true, "data": { "resources": [ + { "key": "appointments", "label": "نوبت‌ها", + "actions": [ { "key": "view", "label": "مشاهده" }, … ] } +] } } +``` + +آرایه است نه object، تا ترتیبِ نمایش تضمین شود؛ ترتیبِ کلیدهای JSON قرارداد نیست. + +**نحوه تست:** +```bash +TOKEN=... # 09390039833 / QaTest@1234 +curl -sk https://clinic-pro.ddev.site/api/v1/permission-catalog -H "Authorization: Bearer $TOKEN" +``` +باید همهٔ منابع بیایند. بدون توکن → ۴۰۱. + +--- + +### ۴. هر دو UI از کاتالوگ رندر شوند + +`RESOURCE_LABELS` و `EMPTY_PERMISSIONS` و `PERMISSION_SECTIONS` حذف می‌شوند و جایشان +یک hook می‌آید: + +```ts +// assets/admin/hooks/usePermissionCatalog.ts +export function usePermissionCatalog() { + return useQuery({ + queryKey: ['permission-catalog'], + queryFn: () => api.get>('/api/v1/permission-catalog'), + staleTime: Infinity, + }); +} +``` + +`clinicOnly` که امروز در `PERMISSION_SECTIONS` است باید به رجیستری منتقل شود، وگرنه +همان منطق در فرانت هاردکد می‌ماند. + +سه حالت داده اجباری است: تا وقتی کاتالوگ نیامده، فرم اسکلتون نشان دهد نه فهرست خالی — +«هیچ مجوزی وجود ندارد» با «در حال خواندن» یکی نیست. + +**نحوه تست:** vitest برای هر دو کامپوننت با mock کردن کاتالوگ؛ سناریو: کاتالوگ با سه +منبع → سه بخش رندر شود. سپس یک منبع به mock اضافه کن و بررسی کن بدون تغییر کد ظاهر شود +— این همان «داینامیک بودن» است و باید تست داشته باشد. + +--- + +### ۵. گیتِ درستِ صفحه‌ها + +`App.tsx` — پنج صفحهٔ منابع از `appointment_settings` به `resources` منتقل شوند و +`treatment-cases` به `treatment`. + +سمت backend هم باید همان‌جا بسته شود، وگرنه گیتِ فرانت فقط دکوراسیون است: +`TreatmentCaseController` و کنترلرهای `src/Resource/` باید +`secretaryAccess->denyUnlessGranted($user, 'treatment', 'view')` بزنند — الگوی موجود در +`StaffController::list()` را ببین. + +> **این را قبل از کد بررسی کن:** آیا صفحهٔ دیگری هم مجوزِ قرضی دارد؟ خروجی این دستور را +> با فهرست صفحه‌ها مقایسه کن: +> ```bash +> grep -oE 'path="[a-z-]+"|permission=\{\[[^]]*\]' assets/admin/App.tsx +> ``` + +**نحوه تست:** منشی‌ای با `treatment.view = false` بساز، توکنش را بگیر و +`GET /api/v1/treatment-cases` بزن → باید ۴۰۳ بدهد. بعد `true` کن و ۲۰۰ بگیر. + +--- + +### ۶. بررسی و تست همهٔ صفحه‌ها + +کاربر صریح خواسته «همه صفحات بررسی و تست شود». این یعنی یک عبورِ سیستماتیک، نه نگاه +اجمالی: + +۱. با کاربر منشی و **همهٔ مجوزها خاموش**، هر route را باز کن. هیچ صفحه‌ای نباید داده + نشان دهد؛ همه باید به داشبورد برگردند. +۲. یکی‌یکی `view` هر منبع را روشن کن و بررسی کن **فقط** صفحه‌های همان منبع باز شوند. +۳. همان دو مرحله برای پزشکِ دعوت‌شده. + +اسکریپت کمکی برای مرحلهٔ ۱ (درایورِ اسکرین‌شات آدرسِ نهایی را گزارش می‌کند): +```bash +node .claude/skills/redesign-page/driver.mjs shot "" --out /tmp/x.png --wait 5000 +# ⚠ WRONG PAGE یعنی گیت کار کرده +``` + +نتیجه را به‌صورت جدول گزارش کن: route، منبعِ گیت، رفتار مشاهده‌شده. **صفحه‌ای که تست +نشده را «تست شد» ننویس.** + +--- + +## نکات مهم + +- **رجیستری فقط ساختار می‌دهد، نه سیاست.** پیش‌فرضِ هر نقش در همان Entity می‌ماند. + آوردنشان به رجیستری یعنی یک فایل که هم «چه چیزی هست» و هم «چه کسی چه دارد» را + می‌داند — دو مسئولیت. +- **هیچ migration دادهٔ انبوهی ننویس.** `merge()` در زمان خواندن کار را می‌کند و + ستون `json` هر دو جدول نیازی به تغییر schema ندارد. migration فقط اگر ستونی اضافه + شود، که اینجا نمی‌شود. +- مالک کلینیک و ادمین همیشه مجازند — این قاعده در `ClinicDoctorPermissionChecker::can()` + است و دست نمی‌خورد. مالک هرگز نباید بتواند خودش را قفل کند. +- `usePermissions` نبودِ `permissions` را «محدودیتی نیست» می‌خواند (محیط شخصی). این + رفتار نباید عوض شود؛ رجیستری فقط جایی اثر دارد که مجوز واقعاً ذخیره شده باشد. +- بعد از تغییر اندپوینت‌ها، `docs/api/secretary.md` و `docs/api/clinic.md` در همان + جلسه به‌روز شوند، با JSON واقعی از اجرای واقعی. +- تستِ «داینامیک بودن» را جدی بگیر: تستی که فقط منابعِ امروز را چک کند، فردا که منبع + تازه اضافه شود چیزی به تو نمی‌گوید. تست باید **افزودنِ یک منبع به mock** را بررسی کند. diff --git a/src/Clinic/Entity/ClinicDoctorPermission.php b/src/Clinic/Entity/ClinicDoctorPermission.php index 6b297238..2fabadde 100644 --- a/src/Clinic/Entity/ClinicDoctorPermission.php +++ b/src/Clinic/Entity/ClinicDoctorPermission.php @@ -4,6 +4,7 @@ namespace App\Clinic\Entity; use App\Clinic\Repository\ClinicDoctorPermissionRepository; use App\Doctor\Entity\Doctor; +use App\Shared\Security\PermissionCatalog; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; @@ -18,22 +19,35 @@ use Symfony\Component\Uid\Uuid; #[ORM\UniqueConstraint(name: 'uniq_clinic_doctor_permission', columns: ['clinic_id', 'doctor_id'])] class ClinicDoctorPermission { + /** + * پیش‌فرضِ نقشِ «پزشکِ عضو کلینیک» — سیاست، نه ساختار. فهرستِ منابع و اکشن‌ها + * در PermissionCatalog است و PermissionCatalogTest اجبار می‌کند این آرایه از + * آن بیرون نزند. + * + * پیش‌فرضِ resources و treatment طوری انتخاب شده که دسترسیِ امروزِ پزشک عوض + * نشود: تا پیش از این، صفحاتِ منابع روی appointment_settings و پروندهٔ درمان + * روی appointments سوار بودند و هر دو برای این نقش روشن‌اند. + */ 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], + 'treatment' => ['view' => true, 'update' => true], 'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], - 'services' => ['view' => true, 'update' => false], + 'services' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], 'clinic_info' => ['view' => true, 'update' => false], 'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], 'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'resources' => ['view' => true, 'create' => true, 'update' => true, 'delete' => true], 'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], 'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], 'staff' => ['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], + 'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], + 'subscription' => ['view' => false, 'create' => false], ], ]; @@ -79,7 +93,11 @@ class ClinicDoctorPermission public function getUuid(): string { return $this->uuid; } public function getClinic(): Clinic { return $this->clinic; } public function getDoctor(): Doctor { return $this->doctor; } - public function getPermissions(): array { return $this->permissions; } + /** همیشه شکلِ کاملِ رجیستری — منبعی که بعد از ساختِ این ردیف اضافه شده، پیش‌فرضِ نقش را می‌گیرد. */ + public function getPermissions(): array + { + return PermissionCatalog::merge($this->permissions, self::DEFAULT_PERMISSIONS); + } public function isActive(): bool { return $this->active; } public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } @@ -92,24 +110,17 @@ class ClinicDoctorPermission return false; } - return (bool) ($this->permissions['resources'][$resource][$action] ?? false); + return (bool) ($this->getPermissions()['resources'][$resource][$action] ?? false); } /** ادغام عمقی — فقط منابع/اکشن‌هایی که ارسال شده‌اند تغییر می‌کنند. */ public function mergePermissions(array $patch): void { - $current = $this->permissions; - $resources = $patch['resources'] ?? $patch; + $current = $this->getPermissions(); - foreach ($resources as $resource => $actions) { - if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) { - continue; - } + foreach (PermissionCatalog::filterPatch($patch['resources'] ?? $patch) as $resource => $actions) { foreach ($actions as $action => $value) { - if (!array_key_exists($action, self::DEFAULT_PERMISSIONS['resources'][$resource])) { - continue; - } - $current['resources'][$resource][$action] = (bool) $value; + $current['resources'][$resource][$action] = $value; } } @@ -131,7 +142,7 @@ class ClinicDoctorPermission 'doctor_uuid' => $this->doctor->getUuid(), 'doctor_name' => $this->doctor->getName(), 'active' => $this->active, - 'permissions' => $this->permissions, + 'permissions' => $this->getPermissions(), 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ]; diff --git a/src/Secretary/Entity/DoctorSecretary.php b/src/Secretary/Entity/DoctorSecretary.php index 91bad9c4..0d621dce 100644 --- a/src/Secretary/Entity/DoctorSecretary.php +++ b/src/Secretary/Entity/DoctorSecretary.php @@ -7,6 +7,7 @@ use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; use App\Secretary\Repository\DoctorSecretaryRepository; use App\Shared\Context\EntityContext; +use App\Shared\Security\PermissionCatalog; use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; @@ -23,11 +24,20 @@ class DoctorSecretary public const OWNER_DOCTOR = EntityContext::TYPE_DOCTOR; public const OWNER_CLINIC = EntityContext::TYPE_CLINIC; + /** + * پیش‌فرضِ نقشِ منشی — سیاست، نه ساختار. فهرستِ منابع و اکشن‌ها در + * PermissionCatalog است. + * + * treatment روشن و resources خاموش است تا دسترسیِ امروز عوض نشود: پروندهٔ درمان + * تا پیش از این روی appointments.view سوار بود (روشن) و صفحاتِ منابع روی + * appointment_settings.view (خاموش). + */ 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], + 'treatment' => ['view' => true, 'update' => 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], @@ -38,6 +48,7 @@ class DoctorSecretary 'staff' => ['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], + 'resources' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], 'appointment_settings' => ['view' => false, 'update' => false], 'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], 'subscription' => ['view' => false, 'create' => false], @@ -115,7 +126,11 @@ class DoctorSecretary public function getSecretary(): User { return $this->secretary; } public function getOwnerType(): string { return $this->entityType; } public function getClinic(): ?Clinic { return $this->clinic; } - public function getPermissions(): array { return $this->permissions ?? self::DEFAULT_PERMISSIONS; } + /** همیشه شکلِ کاملِ رجیستری — منبعی که بعد از ساختِ این ردیف اضافه شده، پیش‌فرضِ نقش را می‌گیرد. */ + public function getPermissions(): array + { + return PermissionCatalog::merge($this->permissions ?? [], self::DEFAULT_PERMISSIONS); + } public function getNationalCode(): ?string { return $this->nationalCode; } public function getAddress(): ?string { return $this->address; } public function isActive(): bool { return $this->active; } @@ -136,16 +151,23 @@ class DoctorSecretary public function setOnlineShareEnabled(bool $v): self { $this->onlineShareEnabled = $v; $this->touch(); return $this; } public function setOnlineSharePercent(float $v): self { $this->onlineSharePercent = (string) $v; $this->touch(); return $this; } - /** Deep merge: only provided resources/actions are updated */ + /** + * Deep merge: only provided resources/actions are updated. + * + * هر دو شکلِ ورودی پذیرفته می‌شود — با envelope و بدون آن. صفحهٔ ادمین نقشهٔ + * تخت می‌فرستد و تا پیش از این بی‌صدا نادیده گرفته می‌شد؛ قرینهٔ همین منطق در + * ClinicDoctorPermission::mergePermissions است. + */ public function mergePermissions(array $patch): void { - $current = $this->getPermissions(); + $current = $this->getPermissions(); + $resources = $patch['resources'] ?? $patch; - if (isset($patch['resources']) && is_array($patch['resources'])) { - foreach ($patch['resources'] as $resource => $actions) { - if (!is_array($actions)) continue; + // قبلاً هر کلیدی پذیرفته و ذخیره می‌شد؛ حالا مثل پزشک فقط منابعِ رجیستری. + if (is_array($resources)) { + foreach (PermissionCatalog::filterPatch($resources) as $resource => $actions) { foreach ($actions as $action => $value) { - $current['resources'][$resource][$action] = (bool) $value; + $current['resources'][$resource][$action] = $value; } } } diff --git a/src/Shared/Security/PermissionCatalog.php b/src/Shared/Security/PermissionCatalog.php new file mode 100644 index 00000000..d6f42d3a --- /dev/null +++ b/src/Shared/Security/PermissionCatalog.php @@ -0,0 +1,273 @@ +}> + */ + public const RESOURCES = [ + 'appointments' => [ + 'label' => 'مدیریت نوبت‌ها', + 'actions' => [ + 'view' => 'مشاهده نوبت‌ها', + 'create' => 'ایجاد نوبت', + 'cancel' => 'لغو نوبت', + 'update_status' => 'تغییر وضعیت نوبت', + ], + ], + 'patients' => [ + 'label' => 'پرونده بیماران', + 'actions' => [ + 'view' => 'مشاهده بیماران', + 'create' => 'ایجاد بیمار', + 'update' => 'ویرایش بیمار', + 'delete' => 'حذف بیمار', + ], + ], + 'treatment' => [ + 'label' => 'دوره‌های درمان', + 'actions' => [ + 'view' => 'مشاهده دوره‌های درمان', + 'update' => 'ویرایش دوره درمان', + ], + ], + 'payments' => [ + 'label' => 'مدیریت پرداخت‌ها', + 'actions' => [ + 'view' => 'مشاهده پرداخت‌ها', + 'create' => 'ثبت پرداخت', + 'update' => 'ویرایش پرداخت', + 'delete' => 'حذف پرداخت', + ], + ], + 'insurances' => [ + 'label' => 'مدیریت بیمه‌ها', + 'actions' => [ + 'view' => 'مشاهده بیمه‌ها', + 'create' => 'ایجاد بیمه', + 'update' => 'ویرایش بیمه', + 'delete' => 'حذف بیمه', + ], + ], + 'addresses' => [ + 'label' => 'آدرس‌ها', + 'actions' => [ + 'view' => 'مشاهده آدرس‌ها', + 'create' => 'ایجاد آدرس', + 'update' => 'ویرایش آدرس', + 'delete' => 'حذف آدرس', + ], + ], + 'clinic_info' => [ + 'label' => 'اطلاعات کلینیک', + 'actions' => [ + 'view' => 'مشاهده اطلاعات', + 'update' => 'ویرایش اطلاعات', + ], + ], + 'services' => [ + 'label' => 'خدمات و تعرفه‌ها', + 'actions' => [ + 'view' => 'مشاهده خدمات', + 'create' => 'ایجاد خدمت', + 'update' => 'ویرایش خدمت', + 'delete' => 'حذف خدمت', + ], + ], + 'inventory' => [ + 'label' => 'انبار', + 'actions' => [ + 'view' => 'مشاهده انبار', + 'create' => 'ایجاد کالا/بسته', + 'update' => 'ویرایش انبار', + 'delete' => 'حذف از انبار', + ], + ], + 'staff' => [ + 'label' => 'پرسنل', + 'actions' => [ + 'view' => 'مشاهده پرسنل', + 'create' => 'افزودن پرسنل', + 'update' => 'ویرایش پرسنل', + 'delete' => 'حذف پرسنل', + ], + ], + 'tags' => [ + 'label' => 'تگ‌ها', + 'actions' => [ + 'view' => 'مشاهده تگ‌ها', + 'create' => 'ایجاد تگ', + 'update' => 'ویرایش تگ', + 'delete' => 'حذف تگ', + ], + ], + 'discounts' => [ + 'label' => 'تخفیف‌ها', + 'actions' => [ + 'view' => 'مشاهده تخفیف‌ها', + 'create' => 'ایجاد تخفیف', + 'update' => 'ویرایش تخفیف', + 'delete' => 'حذف تخفیف', + ], + ], + 'sms' => [ + 'label' => 'پیامک‌ها', + 'actions' => [ + 'view' => 'مشاهده پیامک/کیف پول', + 'create' => 'شارژ/ارسال', + 'update' => 'ویرایش تنظیمات', + 'delete' => 'حذف', + ], + ], + 'appointment_settings' => [ + 'label' => 'تنظیمات نوبت‌دهی', + 'actions' => [ + 'view' => 'مشاهده تنظیمات', + 'update' => 'ویرایش تنظیمات', + ], + ], + 'resources' => [ + 'label' => 'منابع و دستگاه‌ها', + 'actions' => [ + 'view' => 'مشاهده منابع', + 'create' => 'ایجاد منبع', + 'update' => 'ویرایش منبع', + 'delete' => 'حذف منبع', + ], + ], + 'clinic_doctors' => [ + 'label' => 'مدیریت پزشکان کلینیک', + 'clinicOnly' => true, + 'actions' => [ + 'view' => 'مشاهده پزشکان', + 'create' => 'افزودن پزشک', + 'update' => 'ویرایش پزشک', + 'delete' => 'حذف پزشک', + ], + ], + 'subscription' => [ + 'label' => 'خرید اشتراک', + 'actions' => [ + 'view' => 'مشاهده اشتراک', + 'create' => 'خرید/فعال‌سازی اشتراک', + ], + ], + ]; + + public const VERSION = 1; + + public static function hasResource(string $resource): bool + { + return isset(self::RESOURCES[$resource]); + } + + public static function hasAction(string $resource, string $action): bool + { + return isset(self::RESOURCES[$resource]['actions'][$action]); + } + + /** شکلِ کاملِ رجیستری با همهٔ اکشن‌ها خاموش. */ + public static function blank(): array + { + $resources = []; + foreach (self::RESOURCES as $resource => $meta) { + foreach (array_keys($meta['actions']) as $action) { + $resources[$resource][$action] = false; + } + } + + return ['version' => self::VERSION, 'resources' => $resources]; + } + + /** + * مقدارِ ذخیره‌شده را روی پیش‌فرضِ نقش می‌نشاند و نتیجه را به شکلِ رجیستری کامل می‌کند. + * + * منبعی که در رجیستری هست ولی در JSONِ ذخیره‌شده نیست (یعنی بعد از ساختِ آن + * ردیف اضافه شده) مقدارِ پیش‌فرضِ نقش را می‌گیرد، نه false — وگرنه هر منبع تازه + * برای همهٔ ردیف‌های موجود خاموش می‌ماند و «داینامیک بودن» روی دادهٔ واقعی + * کار نمی‌کند. مقدارِ ذخیره‌شده هرگز بازنویسی نمی‌شود. + * + * @param array $stored envelope ذخیره‌شده یا فقط resources + * @param array $roleDefaults envelope پیش‌فرضِ نقش + */ + public static function merge(array $stored, array $roleDefaults): array + { + $storedResources = $stored['resources'] ?? $stored; + $defaultResources = $roleDefaults['resources'] ?? $roleDefaults; + $resources = []; + + foreach (self::RESOURCES as $resource => $meta) { + foreach (array_keys($meta['actions']) as $action) { + $resources[$resource][$action] = (bool) ( + $storedResources[$resource][$action] + ?? $defaultResources[$resource][$action] + ?? false + ); + } + } + + return [ + 'version' => (int) ($stored['version'] ?? self::VERSION), + 'resources' => $resources, + ]; + } + + /** + * فقط کلیدهای شناخته‌شده را نگه می‌دارد — برای patchهایی که از کلاینت می‌آیند. + * کلیدِ ناشناخته بی‌صدا کنار گذاشته می‌شود، همان رفتاری که کلاینت‌های فعلی + * روی آن حساب کرده‌اند. + */ + public static function filterPatch(array $resources): array + { + $clean = []; + foreach ($resources as $resource => $actions) { + if (!is_array($actions) || !self::hasResource($resource)) { + continue; + } + foreach ($actions as $action => $value) { + if (!self::hasAction($resource, $action)) { + continue; + } + $clean[$resource][$action] = (bool) $value; + } + } + + return $clean; + } + + /** شکلِ API — آرایه است نه object تا ترتیبِ نمایش قرارداد باشد. */ + public static function toApiArray(): array + { + $out = []; + foreach (self::RESOURCES as $key => $meta) { + $actions = []; + foreach ($meta['actions'] as $action => $label) { + $actions[] = ['key' => $action, 'label' => $label]; + } + $out[] = [ + 'key' => $key, + 'label' => $meta['label'], + 'clinic_only' => $meta['clinicOnly'] ?? false, + 'actions' => $actions, + ]; + } + + return $out; + } +} diff --git a/tests/Clinic/ClinicDoctorPermissionTest.php b/tests/Clinic/ClinicDoctorPermissionTest.php index a4557fff..ff8d7273 100644 --- a/tests/Clinic/ClinicDoctorPermissionTest.php +++ b/tests/Clinic/ClinicDoctorPermissionTest.php @@ -7,6 +7,7 @@ use App\Clinic\Entity\Clinic; use App\Clinic\Entity\ClinicDoctorPermission; use App\Clinic\Repository\ClinicDoctorPermissionRepository; use App\Doctor\Entity\Doctor; +use App\Shared\Security\PermissionCatalog; use App\Tests\ApiTestCase; /** @@ -46,8 +47,10 @@ class ClinicDoctorPermissionTest extends ApiTestCase self::assertSame(200, $this->responseCode()); self::assertTrue($res['data']['active']); + // ترتیب و مجموعهٔ کلیدها را PermissionCatalog تعیین می‌کند، نه ترتیبِ + // نوشتنِ DEFAULT_PERMISSIONS؛ مقادیر همان پیش‌فرضِ نقش می‌مانند. self::assertSame( - ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources'], + PermissionCatalog::merge([], ClinicDoctorPermission::DEFAULT_PERMISSIONS)['resources'], $res['data']['permissions']['resources'], 'a member added before this feature gets defaults on first read', ); diff --git a/tests/Shared/PermissionCatalogTest.php b/tests/Shared/PermissionCatalogTest.php new file mode 100644 index 00000000..38eab7f7 --- /dev/null +++ b/tests/Shared/PermissionCatalogTest.php @@ -0,0 +1,122 @@ +assertSame( + array_keys(PermissionCatalog::RESOURCES), + array_keys($blank['resources']), + 'blank() باید دقیقاً همان منابع رجیستری را داشته باشد', + ); + + foreach (PermissionCatalog::RESOURCES as $resource => $meta) { + foreach (array_keys($meta['actions']) as $action) { + $this->assertFalse( + $blank['resources'][$resource][$action], + "{$resource}.{$action} باید در blank خاموش باشد", + ); + } + } + } + + public function testMergeKeepsStoredValuesUntouched(): void + { + $stored = ['version' => 1, 'resources' => [ + 'appointments' => ['view' => true, 'create' => false], + ]]; + $defaults = ['version' => 1, 'resources' => [ + 'appointments' => ['view' => false, 'create' => true], + ]]; + + $merged = PermissionCatalog::merge($stored, $defaults); + + $this->assertTrue($merged['resources']['appointments']['view']); + $this->assertFalse( + $merged['resources']['appointments']['create'], + 'مقدارِ صریحِ false در stored نباید با پیش‌فرضِ true بازنویسی شود', + ); + } + + public function testMergeFillsResourceMissingFromStoredWithRoleDefault(): void + { + $stored = ['version' => 1, 'resources' => ['appointments' => ['view' => true]]]; + $defaults = ['version' => 1, 'resources' => ['treatment' => ['view' => true, 'update' => false]]]; + + $merged = PermissionCatalog::merge($stored, $defaults); + + $this->assertTrue( + $merged['resources']['treatment']['view'], + 'منبعی که بعد از ساختِ ردیف به رجیستری اضافه شده باید پیش‌فرضِ نقش را بگیرد', + ); + $this->assertFalse($merged['resources']['treatment']['update']); + } + + public function testMergeFallsBackToFalseWhenNeitherStoredNorDefaultHasIt(): void + { + $merged = PermissionCatalog::merge([], []); + + foreach (PermissionCatalog::RESOURCES as $resource => $meta) { + foreach (array_keys($meta['actions']) as $action) { + $this->assertFalse($merged['resources'][$resource][$action]); + } + } + } + + public function testFilterPatchDropsUnknownResourcesAndActions(): void + { + $clean = PermissionCatalog::filterPatch([ + 'appointments' => ['view' => true, 'teleport' => true], + 'unknown_thing' => ['view' => true], + 'patients' => 'not-an-array', + ]); + + $this->assertSame(['appointments' => ['view' => true]], $clean); + } + + /** پیش‌فرضِ هر نقش فقط حق دارد از منابعِ رجیستری حرف بزند. */ + public function testRoleDefaultsUseOnlyKnownResourcesAndActions(): void + { + $roles = [ + 'secretary' => DoctorSecretary::DEFAULT_PERMISSIONS, + 'clinic_doctor' => ClinicDoctorPermission::DEFAULT_PERMISSIONS, + ]; + + foreach ($roles as $role => $defaults) { + foreach ($defaults['resources'] as $resource => $actions) { + $this->assertTrue( + PermissionCatalog::hasResource($resource), + "منبع «{$resource}» در پیش‌فرضِ {$role} هست ولی در رجیستری نیست", + ); + foreach (array_keys($actions) as $action) { + $this->assertTrue( + PermissionCatalog::hasAction($resource, $action), + "اکشن «{$resource}.{$action}» در پیش‌فرضِ {$role} هست ولی در رجیستری نیست", + ); + } + } + } + } + + public function testApiArrayKeepsRegistryOrder(): void + { + $api = PermissionCatalog::toApiArray(); + + $this->assertSame( + array_keys(PermissionCatalog::RESOURCES), + array_column($api, 'key'), + ); + $this->assertTrue( + $api[array_search('clinic_doctors', array_column($api, 'key'), true)]['clinic_only'], + ); + } +} diff --git a/tests/Shared/PermissionRegistryEntityTest.php b/tests/Shared/PermissionRegistryEntityTest.php new file mode 100644 index 00000000..9aa35610 --- /dev/null +++ b/tests/Shared/PermissionRegistryEntityTest.php @@ -0,0 +1,135 @@ +newInstanceWithoutConstructor(); + (new \ReflectionProperty(ClinicDoctorPermission::class, 'permissions')) + ->setValue($perm, ClinicDoctorPermission::DEFAULT_PERMISSIONS); + + return $perm; + } + + private function secretary(): DoctorSecretary + { + $ref = new \ReflectionClass(DoctorSecretary::class); + + return $ref->newInstanceWithoutConstructor(); + } + + // ── پزشکِ عضو کلینیک ────────────────────────────────────────────────── + + /** تا پیش از این services فقط view/update داشت و create بی‌صدا دور ریخته می‌شد. */ + public function testClinicDoctorCanNowStoreServicesCreate(): void + { + $perm = $this->clinicPermission(); + + $perm->mergePermissions(['resources' => ['services' => ['create' => true, 'delete' => true]]]); + + $this->assertTrue($perm->can('services', 'create')); + $this->assertTrue($perm->can('services', 'delete')); + } + + public function testClinicDoctorDropsUnknownResourceWithoutTouchingTheRest(): void + { + $perm = $this->clinicPermission(); + + $perm->mergePermissions(['resources' => [ + 'not_a_resource' => ['view' => true], + 'patients' => ['delete' => true], + ]]); + + $this->assertArrayNotHasKey('not_a_resource', $perm->getPermissions()['resources']); + $this->assertTrue($perm->can('patients', 'delete')); + } + + public function testClinicDoctorPermissionsAlwaysExposeFullRegistry(): void + { + $resources = $this->clinicPermission()->getPermissions()['resources']; + + $this->assertSame(array_keys(PermissionCatalog::RESOURCES), array_keys($resources)); + } + + // ── منشی ───────────────────────────────────────────────────────────── + + /** صفحهٔ ادمین نقشهٔ تخت می‌فرستد؛ تا پیش از این کل patch نادیده می‌رفت. */ + public function testSecretaryAcceptsFlatPatchWithoutEnvelope(): void + { + $secretary = $this->secretary(); + + $secretary->mergePermissions(['patients' => ['update' => true]]); + + $this->assertTrue($secretary->getPermissions()['resources']['patients']['update']); + } + + public function testSecretaryAcceptsEnvelopePatch(): void + { + $secretary = $this->secretary(); + + $secretary->mergePermissions(['version' => 1, 'resources' => ['sms' => ['view' => true]]]); + + $this->assertTrue($secretary->getPermissions()['resources']['sms']['view']); + } + + /** تا پیش از این منشی هیچ اعتبارسنجی نداشت و هر کلیدی ذخیره می‌شد. */ + public function testSecretaryDropsUnknownKeys(): void + { + $secretary = $this->secretary(); + + $secretary->mergePermissions(['resources' => [ + 'ghost_resource' => ['view' => true], + 'patients' => ['teleport' => true], + ]]); + + $resources = $secretary->getPermissions()['resources']; + $this->assertArrayNotHasKey('ghost_resource', $resources); + $this->assertArrayNotHasKey('teleport', $resources['patients']); + } + + /** + * ردیفِ ساخته‌شده پیش از افزودن treatment به رجیستری — نباید null یا خطا بدهد + * و باید پیش‌فرضِ نقش را بگیرد. + */ + public function testLegacyStoredJsonGetsRegistryDefaultsForNewResources(): void + { + $secretary = $this->secretary(); + $legacy = new \ReflectionProperty(DoctorSecretary::class, 'permissions'); + $legacy->setValue($secretary, ['version' => 1, 'resources' => [ + 'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true], + ]]); + + $resources = $secretary->getPermissions()['resources']; + + $this->assertTrue($resources['treatment']['view'], 'treatment باید پیش‌فرضِ نقش منشی را بگیرد'); + $this->assertFalse($resources['resources']['view']); + $this->assertTrue($resources['appointments']['view'], 'مقدارِ ذخیره‌شده نباید عوض شود'); + } + + public function testExplicitlyDisabledStoredValueSurvivesMerge(): void + { + $secretary = $this->secretary(); + $stored = new \ReflectionProperty(DoctorSecretary::class, 'permissions'); + $stored->setValue($secretary, ['version' => 1, 'resources' => [ + 'appointments' => ['view' => false], + ]]); + + $this->assertFalse( + $secretary->getPermissions()['resources']['appointments']['view'], + 'خاموشیِ صریح نباید با پیش‌فرضِ روشنِ نقش بازنویسی شود', + ); + } +}