# رجیستری واحدِ مجوزها برای منشی و پزشکِ دعوت‌شده

## پروژه

`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** را بررسی کند.
