# رجیستری واحدِ مجوزها برای منشی و پزشکِ دعوت‌شده ## پروژه `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** را بررسی کند.