feat(permissions): single registry for secretary and clinic-doctor permissions

The list of permissionable resources was duplicated in six places that had
already diverged: both permission entities, three admin UI files and the
SecretaryPermissions TypeScript interface. Adding a resource meant editing all
of them, so new pages borrowed an unrelated resource instead — five resource
pages sat on appointment_settings.view and treatment-cases on appointments.view.

PermissionCatalog is now the only place that says which resources and actions
exist. Each entity keeps its own DEFAULT_PERMISSIONS, but as role policy only;
a test asserts those defaults never name a resource the registry doesn't have.

getPermissions() merges the stored JSON over the role defaults, so a resource
added to the registry later resolves to the role default instead of silently
false for every existing row. Explicitly stored values are never overwritten,
and no data migration is needed.

Two asymmetries fixed along the way:
- ClinicDoctorPermission validated writes against its own DEFAULT_PERMISSIONS,
  so services.create/delete could never be stored for an invited doctor.
- DoctorSecretary had no validation at all and would store any key, and it only
  read $patch['resources'] — the admin SecretariesPage sends a flat map, so its
  permission edit silently did nothing. Both entities now accept either shape
  and filter through the registry.

New resources 'resources' and 'treatment' are registered with defaults chosen to
preserve today's effective access, since both pages are currently gated on a
borrowed resource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 17:32:28 +03:30
co-authored by Claude Opus 5
parent 0360a5e46f
commit 1d1efd7a85
7 changed files with 954 additions and 22 deletions
@@ -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<string, { label: string; actions: Record<string, string> }> = {
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
<Route path="resources" element={<RoleRoute roles={['doctor','clinic','secretary']} permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="treatment-cases" element={<RoleRoute roles={['doctor','clinic','secretary']} permission={['appointments', 'view']}><TreatmentCasesPage /></RoleRoute>} />
```
---
## وظایف
### ۱. رجیستری منابع — منبع واحد
`src/Shared/Security/PermissionCatalog.php`
یک کلاس با یک ثابت: هر منبع، actionهایش، و برچسب فارسی. **هیچ پیش‌فرضی اینجا نیست**
اینجا فقط «چه چیزهایی وجود دارند».
```php
final class PermissionCatalog
{
/** @var array<string, array{label: string, actions: array<string, string>}> */
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<ApiResponse<{ resources: CatalogResource[] }>>('/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 "<url>" --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** را بررسی کند.
+25 -14
View File
@@ -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,
];
+28 -6
View File
@@ -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();
$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;
}
}
}
+273
View File
@@ -0,0 +1,273 @@
<?php
namespace App\Shared\Security;
/**
* فهرست واحدِ منابع و اکشن‌های قابل‌مجوزدهی.
*
* این کلاس فقط می‌گوید «چه چیزهایی وجود دارند»، نه «چه کسی چه دارد». پیش‌فرضِ هر
* نقش در Entity خودش می‌ماند (DoctorSecretary / ClinicDoctorPermission) چون سیاست
* است، نه ساختار.
*
* قبلاً همین فهرست شش جا تکرار شده بود — دو Entity، سه فایل UI و یک interface
* تایپ‌اسکریپت — و از هم واگرا شده بودند. صفحهٔ تازه به‌جای منبع خودش، مجوز
* نزدیک‌ترین صفحهٔ موجود را قرض می‌گرفت. با یک منبعِ واحد، افزودن صفحه یعنی یک
* ردیف اینجا و بس.
*/
final class PermissionCatalog
{
/**
* ترتیب کلیدها ترتیبِ نمایش در UI است.
*
* @var array<string, array{label: string, clinicOnly?: bool, actions: array<string, string>}>
*/
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;
}
}
+4 -1
View File
@@ -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',
);
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Tests\Shared;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Security\PermissionCatalog;
use PHPUnit\Framework\TestCase;
class PermissionCatalogTest extends TestCase
{
public function testBlankCoversEveryResourceAndActionAsFalse(): void
{
$blank = PermissionCatalog::blank();
$this->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'],
);
}
}
@@ -0,0 +1,135 @@
<?php
namespace App\Tests\Shared;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Security\PermissionCatalog;
use PHPUnit\Framework\TestCase;
/**
* رفتارِ هر دو Entity مجوز بعد از اتصال به رجیستری واحد.
*
* هر دو بدون constructor ساخته می‌شوند تا تست به Clinic/Doctor/User و پایگاه‌داده
* وابسته نشود؛ فقط همان دو فیلدی که منطقِ مجوز می‌خواند مقدار می‌گیرند.
*/
class PermissionRegistryEntityTest extends TestCase
{
private function clinicPermission(): ClinicDoctorPermission
{
$perm = (new \ReflectionClass(ClinicDoctorPermission::class))->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'],
'خاموشیِ صریح نباید با پیش‌فرضِ روشنِ نقش بازنویسی شود',
);
}
}