# گِیتِ مجوز برای ServiceCatalogController

## پروژه

`clinicpro` (backend + پنل ادمین). cross-repo نیست — بررسی شد، هیچ‌کدام از دو کلاینت
دیگر این اندپوینت‌ها را مصرف نمی‌کنند.

## زمینه

بعد از یکی‌سازیِ مجوزها در `PermissionCatalog`، یک عبورِ سیستماتیک روی همهٔ routeها با
منشیِ واقعی و **همهٔ مجوزها خاموش** انجام شد. ۱۰ منبع از ۱۰ منبع درست ۴۰۳ دادند، ولی
`GET /api/v1/service-categories/tree` با همهٔ مجوزها خاموش هم `200` برگرداند.

علتش این است که `ServiceCatalogController` روی کلاسش فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]`
دارد و **هیچ‌کدام از ۱۵ routeاش** گِیت مجوز ندارند.

## مشکل / هدف

هر کاربرِ لاگین‌کرده‌ای که محیطِ فعال دارد — از جمله منشی با `services` کاملاً خاموش —
می‌تواند در **محیط خودش** دستهٔ سرویس بسازد، ویرایش و حذف کند، گروه بسازد، اقلام گروه
را جایگزین کند و روابط و override‌های شعبه‌ای سرویس را بازنویسی کند.

### دامنهٔ دقیق ریسک — این IDOR نیست

مالکیتِ محیط **کاملاً enforce شده** است. هر route از `requireCategory` / `requireGroup` /
`requireItem` رد می‌شود و همه به `owned()` می‌رسند:

```php
private function owned(User $user, ?object $entity, string $message): object
{
    [$entityType, $entityId] = $this->branches->pair($user);

    if ($entity === null || !$this->ownership->belongsToPair($entityType, $entityId, $entity)) {
        throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, $message, 404);
    }

    return $entity;
}
```

پس دادهٔ هیچ محیطی به محیط دیگر نشت نمی‌کند. مسئله **بالا رفتن سطح دسترسی داخل همان
محیط** است: منشی‌ای که مالک صریحاً `services` را برایش خاموش کرده، همچنان کاتالوگ سرویس
همان کلینیک را می‌نویسد. شدت: **متوسط**، نه بحرانی.

### ⚠ دلیلی که قبلاً برای دست‌نزدن گفته شد، غلط بود

در گزارش قبلی نوشته شد «endpointهایش در جریانِ ثبت نوبت مصرف می‌شوند و بستنشان
نوبت‌دهی منشی را می‌شکند». این ادعا **بررسی نشده بود و رد شد**:

۱. `service-item/{uuid}/groups`، `item-group/*`، `service-item/{uuid}/relations` و
   `service-selection/validate` در **هیچ‌کدام** از سه کلاینت مصرف‌کننده ندارند. تنها
   ارجاعشان hookهای بدون مصرف‌کننده در `assets/admin/hooks/useServiceCatalog.ts` است
   (`useServiceGroups`، `useServiceRelations`، `useSelectionPreview` — هیچ کامپوننتی
   صداشان نمی‌زند). در `nobat724_front` و `clinic-pro-tauri` هم صفر ارجاع.

۲. مهم‌تر: کنترلرِ خواهرِ همین دامنه، `ClinicServiceController`، **همین حالا** هر
   خواندنِ سرویس را پشت `services.view` بسته است — از جمله
   `GET /api/v1/service-items/{sectionUuid}` که مودالِ ثبت نوبت از آن سرویس می‌خواند.
   یعنی هر جریانی که به سرویس نیاز دارد **از قبل** به `services.view` نیاز دارد.
   بستنِ کاتالوگ چیز تازه‌ای نمی‌شکند.

پس مسیر درست همان گِیتِ ساده است، نه استثنا و نه fallback.

### تنها مصرف‌کنندگانِ واقعی

| hook | صفحه/کامپوننت | route |
|---|---|---|
| `useCatalogCategories` | `CatalogCategoriesPage` (`/admin/service-categories`) | tree، create/update/delete دسته |
| `useCategoryIncludes` | `CatalogCategoriesPage` + `ServiceCategoryTab` | includes: list/add/remove |
| `useCatalogCategories` | `ServiceCategoryTab` داخل `ServiceDetailPage` | tree |

هر دو صفحه از قبل پشت `permission={['services','view']}` در `App.tsx` هستند. یعنی
گِیتِ فرانت هست و فقط گِیتِ سمت API غایب است.

---

## معیار پذیرش

- ✅ موفق: منشی با `services.view = true` → `GET /api/v1/service-categories/tree` جواب
  `200` و همان درخت قبلی. صفحهٔ `/admin/service-categories` مثل امروز کار می‌کند.
- ✅ موفق: منشی با `services.update = true` → `PATCH /api/v1/service-category/{uuid}`
  جواب `200`.
- ❌ خطا: منشی با `services` کاملاً خاموش → **هر ۱۵ route** جواب `403` با
  `ERR_FORBIDDEN_001`. امروز `tree` جواب `200` می‌دهد؛ همین تفاوتْ تستِ اصلی است.
- ❌ خطا: منشی با `services.view = true` ولی `services.create = false` →
  `POST /api/v1/service-category` جواب `403`، در حالی که `tree` همچنان `200`.
- ⚠️ مرزی: پزشکِ عضو کلینیک با `services.update = false` → نوشتن‌ها `403`، خواندن‌ها
  `200` (پیش‌فرضِ نقشش `view: true` است).
- ⚠️ مرزی: مالکِ کلینیک، پزشکِ مطبِ شخصی و ادمین بدون تغییر عبور می‌کنند —
  `denyUnlessGranted` فقط نقشِ خودش را محدود می‌کند.
- ⚠️ مرزی: مودالِ ثبت نوبت برای منشیِ دارای `services.view` نباید عوض شود. این را
  واقعاً باز کن و ببین، نه از روی کد حدس بزن.

---

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/ClinicService/Controller/ServiceCatalogController.php` | ۱۵ routeِ بدون گِیت |
| `src/ClinicService/Controller/ClinicServiceController.php` | **الگوی مرجع** — `denyServices()` |
| `assets/admin/pages/CatalogCategoriesPage.tsx` | `canUpdate` واحد برای هر سه دکمه |
| `assets/admin/components/ServiceCategoryTab.tsx` | prop به نام `canEdit` |
| `assets/admin/pages/ServiceDetailPage.tsx` | `canEdit={canUpdate}` را پاس می‌دهد |
| `docs/api/clinic-services.md` | سند اندپوینت‌ها |
| `docs/api/secretary.md` | جدول enforcement — سطر `services` |

---

## وضعیت فعلی

### کلاس بدون هیچ گِیتی جز احراز هویت

```php
#[OA\Tag(name: 'Treatment')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ServiceCatalogController extends BaseController
```

### نمونهٔ نوشتنِ باز — ساخت دسته

```php
#[Route('/api/v1/service-category', name: 'service_category_create', methods: ['POST'])]
public function createCategory(#[CurrentUser] User $user, Request $request): JsonResponse
{
    $data = json_decode($request->getContent(), true);
    $name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : '';
    // ← هیچ denyUnlessGranted‌ای اینجا نیست
```

### الگوی مرجع در کنترلرِ خواهر — همین دامنه

```php
/** گِیتِ ترکیبی: منشی + پزشکِ عضوِ کلینیک (هرکدام فقط نقشِ خودش را محدود می‌کند). */
private function denyServices(User $user, string $action): void
{
    $this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
    $this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
}
```

و در عمل per-action صدا زده می‌شود: ۵ بار `view`، ۲ بار `create`، ۲ بار `update`.

### فرانت — یک توگل برای هر سه عمل

```tsx
// CatalogCategoriesPage.tsx:33
const canUpdate = can('services', 'update');
// همین یکی هم دکمهٔ «افزودن» را کنترل می‌کند، هم ویرایش، هم حذف
```

---

## وظایف

### ۱. گِیتِ per-action روی هر ۱۵ route

`denyServices()` را عیناً مثل `ClinicServiceController` به `ServiceCatalogController`
اضافه کن (همان دو checker از constructor تزریق می‌شوند) و اولین خط هر action صدایش بزن.

نگاشت — هر ۱۵ تا، بدون استثنا:

| Action | Route | مجوز |
|---|---|---|
| `tree` | `GET /service-categories/tree` | `view` |
| `listIncludes` | `GET /service-category/{uuid}/includes` | `view` |
| `listGroups` | `GET /service-item/{uuid}/groups` | `view` |
| `validateSelection` | `POST /service-selection/validate` | `view` |
| `createCategory` | `POST /service-category` | `create` |
| `addInclude` | `POST /service-category/{uuid}/includes` | `create` |
| `createGroup` | `POST /service-item/{uuid}/groups` | `create` |
| `updateCategory` | `PATCH /service-category/{uuid}` | `update` |
| `updateGroup` | `PATCH /item-group/{uuid}` | `update` |
| `replaceGroupItems` | `PUT /item-group/{uuid}/items` | `update` |
| `replaceRelations` | `PUT /service-item/{uuid}/relations` | `update` |
| `replaceBranchOverrides` | `PUT /service-item/{uuid}/branch-overrides` | `update` |
| `deleteCategory` | `DELETE /service-category/{uuid}` | `delete` |
| `removeInclude` | `DELETE /service-category/{uuid}/includes/{childUuid}` | `delete` |
| `deleteGroup` | `DELETE /item-group/{uuid}` | `delete` |

`validateSelection` عمداً `view` است نه `create`: چیزی نمی‌سازد و فقط یک انتخاب را
اعتبارسنجی می‌کند؛ POST بودنش به‌خاطر حجمِ بدنه است، نه اثرِ جانبی.

**گِیت قبل از هر کار دیگری بیاید** — قبل از `json_decode`، قبل از `requireCategory`.
وگرنه ترتیبِ خطاها ۴۰۴/۴۲۲ را جای ۴۰۳ برمی‌گرداند و همان oracle‌ای می‌شود که گِیت
قرار بود ببندد.

**نحوه تست:** منشیِ تست `0912000209` / `QaTest@1234`، محیط کلینیک
`c3f1feac-4f56-45da-a3c4-b2182f4d6902`.

```bash
TOK=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
  -H 'Content-Type: application/json' \
  -d '{"mobile_number":"0912000209","password":"QaTest@1234"}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

curl -sk -X POST https://clinic-pro.ddev.site/api/v1/auth/switch-context \
  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -d '{"db_uuid":"c3f1feac-4f56-45da-a3c4-b2182f4d6902","db_type":"clinic"}'

curl -sk -o /dev/null -w "%{http_code}\n" \
  https://clinic-pro.ddev.site/api/v1/service-categories/tree -H "Authorization: Bearer $TOK"
```

> ⚠ **قبل از دست‌زدن به مجوزها backup بگیر و در پایان بایت‌به‌بایت برگردان:**
> ```bash
> ddev mysql -N -e "SELECT id, permission FROM doctor_secretaries WHERE secretary_id=15;" > /tmp/bk.tsv
> ```
> و **توجه:** `JSON_SET` روی مسیرِ تودرتویی که والدش وجود ندارد بی‌صدا کاری نمی‌کند.
> برای خاموش‌کردن حتماً کلِ آبجکت را بنویس:
> ```sql
> JSON_SET(permission,'$.resources.services',
>   JSON_OBJECT('view',false,'create',false,'update',false,'delete',false))
> ```
> و یادت باشد **حذفِ کلید یعنی «پیش‌فرضِ نقش را بگیر»، نه «ممنوع»** — چون
> `getPermissions()` با رجیستری merge می‌کند.

### ۲. هم‌ترازیِ فرانت با گِیتِ per-action

الان یک `canUpdate` هر سه دکمه را کنترل می‌کند. با گِیتِ per-action، منشیِ دارای
`update` ولی بدون `create` دکمهٔ «افزودن» را می‌بیند و ۴۰۳ می‌گیرد.

در `CatalogCategoriesPage.tsx` و `ServiceCategoryTab.tsx`:

```tsx
const canCreate = can('services', 'create');
const canUpdate = can('services', 'update');
const canDelete = can('services', 'delete');
```

و هر دکمه به مجوزِ خودش وصل شود. `ServiceCategoryTab` به‌جای `canEdit: boolean` باید
هر سه را بگیرد؛ `ServiceDetailPage` هم مقادیر درست را پاس بدهد.

**نحوه تست:** vitest برای `CatalogCategoriesPage` — با `can` که فقط `update` را `true`
برمی‌گرداند، دکمهٔ افزودن نباید رندر شود ولی دکمهٔ ویرایش باید.

### ۳. تست backend

فایل تازه `tests/ClinicService/ServiceCatalogPermissionTest.php`، هم‌سبک با
`tests/Secretary/SecretaryResourceEnforcementTest.php`:

- منشی با `services` خاموش → هر ۱۵ route جواب `403`. **حلقه روی فهرست routeها بزن**،
  نه سه‌تا نمونه؛ همین تست است که جلوی routeِ تازهٔ بی‌گِیت را در آینده می‌گیرد.
- `view` روشن → خواندن‌ها `200`، نوشتن‌ها همچنان `403`.
- مالکِ کلینیک با همان مجوزهای خاموش → همه‌چیز `200`.

### ۴. مستندات

- `docs/api/clinic-services.md`: برای هر ۱۵ route ستون Permission اضافه شود.
- `docs/api/secretary.md`: در جدول enforcement، سطر `services` باید
  `ServiceCatalogController` را هم نام ببرد و هشدارِ «گِیت ندارد» حذف شود.

---

## نکات مهم

- **الگو تازه نساز.** `denyServices()` عیناً از `ClinicServiceController` کپی شود؛ همان
  دامنه، همان دو checker، همان معنا. trait مشترک هم لازم نیست — دو متدِ چهارخطی در یک
  دامنه، abstraction نمی‌خواهد.
- **مالکیتِ محیط از قبل درست است؛ دست نزن.** `owned()` و `requireItem()` کارشان را
  می‌کنند. این تسک فقط لایهٔ مجوز را اضافه می‌کند، نه لایهٔ tenant.
- `denyUnlessGranted` برای نقش‌های غیرمنشی/غیرعضو `true` است، پس مالک و ادمین خودبه‌خود
  عبور می‌کنند. «مالک هرگز نباید بتواند خودش را قفل کند» باید برقرار بماند.
- **hookهای بی‌مصرف را در همین تسک حذف نکن.** `useServiceGroups`، `useServiceRelations`
  و `useSelectionPreview` مصرف‌کننده ندارند، ولی حذفشان کارِ این پرامپت نیست و ریسکِ
  بی‌دلیل اضافه می‌کند. فقط در گزارش ذکرشان کن.
- بعد از تغییر، هر دو سوییت کامل سبز بمانند: `ddev exec php bin/phpunit` و
  `npx vitest run`. خط پایه: ۱۵۲۸ تست backend و ۷۹۵ تست frontend.
- `AppointmentEditPage.serviceMode` زیر بار موازی flaky است؛ اگر افتاد تنها اجرایش کن
  و اگر سبز شد به این تغییر ربطی ندارد.
