# مدیریت دسترسی پزشکان کلینیک (Per-doctor permissions) ## زمینه پس از پذیرش دعوت‌نامه، پزشک صرفاً یک سطر در جدول join `clinic_doctors` می‌گیرد و هیچ‌جا نمی‌توان تعیین کرد که این پزشک در آن کلینیک به چه بخش‌هایی دسترسی دارد. امروز نقش او hardcode است: در `buildAvailableContexts()` پزشکِ غیرمالک، context کلینیک را با `role: 'doctor'` و `scope: 'clinic'` می‌گیرد و همین `scope` باعث می‌شود Sidebar فقط داشبورد و «نوبت‌های من» را نشان دهد — بدون هیچ امکان تنظیم. هدف: مدیر کلینیک بتواند از `/admin/settings/clinic-doctors` برای هر پزشک سطح دسترسی تعیین کند، و این دسترسی هم در بک‌اند اعمال شود هم منوی پنل را بسازد. الگوی مرجع در پروژه: سیستم مجوز منشی (`DoctorSecretary`). عیناً همان envelope و همان الگوی UI را تکرار کن، **ولی سه ضعف آن را تکرار نکن** (در «نکات مهم» توضیح داده شده). > این پرامپت پیش‌نیاز `clinic-appointment-settings-tabs.md` است. اول این را اجرا کن. ## مشکل / هدف ۱. جایی برای ذخیره‌ی مجوزِ «پزشک X در کلینیک Y» وجود ندارد. ۲. مجوزها به کلاینت ارسال نمی‌شوند (context پزشکِ عضو کلینیک فیلد `permissions` ندارد). ۳. هیچ primitive سمت فرانت برای gate کردن منو/صفحه بر اساس مجوز وجود ندارد (`FeatureGate` فقط اشتراک را چک می‌کند). ۴. صفحه `/admin/settings/clinic-doctors` برای هر پزشک فقط دو اکشن دارد: مشاهده پروفایل و جداسازی. ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Clinic/Entity/Clinic.php:88-95` | ManyToMany `clinic_doctors` — پیوند فعلی، بدون ستون اضافی | | `src/Secretary/Entity/DoctorSecretary.php:20-30, 104-122, 140` | الگوی مرجع: envelope مجوز، `mergePermissions()`، `toArray()` | | `src/Secretary/Security/SecretaryPermissionChecker.php` | چکر موجود — **کد مرده، هیچ call site ندارد** | | `src/Auth/Controller/AuthController.php:694-765` | `buildAvailableContexts()` — جایی که باید `permissions` اضافه شود | | `src/Clinic/Controller/ClinicController.php` | `GET /api/v1/clinic/doctor-list/{clinicUuid}`، `DELETE /api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}` | | `assets/admin/components/ClinicDoctorsManager.tsx` | UI ردیف هر پزشک — محل دکمه مجوزها | | `assets/admin/pages/SecretariesPage.tsx:15-22, 26+, 82-140` | الگوی مرجع `PermissionsMatrix` و `PERMISSION_LABELS` | | `assets/admin/stores/authStore.ts:4-12` | `ContextItem.permissions?: Record` — تعریف شده ولی هیچ مصرف‌کننده‌ای ندارد | | `assets/admin/components/layout/Sidebar.tsx:52-76` | `buildSections(primaryRole, dbUuid, scope)` — منوی پزشکِ در scope کلینیک | | `assets/admin/App.tsx:117-128` | `RoleRoute` + `blockClinicScope` | | `docs/api/clinic.md` | مستند API کلینیک | ## وضعیت فعلی پیوند کلینیک↔پزشک هیچ ستون اضافی ندارد — `src/Clinic/Entity/Clinic.php:88-95`: ```php #[ORM\ManyToMany(targetEntity: Doctor::class)] #[ORM\JoinTable( name: 'clinic_doctors', joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')], inverseJoinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', onDelete: 'CASCADE')] )] private Collection $doctors; ``` context پزشکِ عضو کلینیک هیچ مجوزی حمل نمی‌کند — `src/Auth/Controller/AuthController.php:711-718`: ```php $isOwner = $clinic->getUser()->getId() === $user->getId(); $contexts[] = [ 'type' => 'clinic', 'db_uuid' => $clinic->getUuid(), 'name' => $clinic->getName() ?? '', 'role' => $isOwner ? 'clinic' : 'doctor', 'scope' => $isOwner ? null : 'clinic', 'doctor_uuid' => $doctor->getUuid(), ]; ``` منوی پزشکِ در scope کلینیک hardcode است — `assets/admin/components/layout/Sidebar.tsx:52-76`: ```tsx if (primaryRole === "doctor" && scope === "clinic") { // فقط داشبورد و «نوبت‌های من» ``` ردیف هر پزشک فقط دو اکشن دارد — `assets/admin/components/ClinicDoctorsManager.tsx`: ```tsx {!readOnly && ( )} ``` ## وظایف ### ۱. Entity جدید `ClinicDoctorPermission` **جدول `clinic_doctors` را به entity تبدیل نکن.** شش نقطه در کد به `Clinic::$doctors` (`getDoctors`/`hasDoctor`/`findByDoctor`/`isDoctorInClinic`/detach endpoint) وابسته‌اند و mapping هم‌زمانِ ManyToMany و entity روی یک جدول، schema tool را دچار تعارض می‌کند. به‌جایش یک جدول موازی بساز: `src/Clinic/Entity/ClinicDoctorPermission.php` — جدول `clinic_doctor_permissions`: | ستون | نوع | توضیح | |---|---|---| | `id` | int, auto | | | `uuid` | string(36) unique | `Uuid::v4()->toRfc4122()` در constructor | | `clinic_id` | ManyToOne Clinic, `nullable: false`, `onDelete: CASCADE` | | | `doctor_id` | ManyToOne Doctor, `nullable: false`, `onDelete: CASCADE` | | | `permission` | json | envelope `{version, resources}` | | `active` | bool, default true | | | `created_at` / `updated_at` | int (Unix) | | `UniqueConstraint(['clinic_id','doctor_id'])`. envelope پیش‌فرض — دقیقاً هم‌شکل `DoctorSecretary::DEFAULT_PERMISSIONS` ولی با منابعِ مربوط به پزشک: ```php 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], 'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], 'services' => ['view' => true, 'update' => false], 'clinic_info' => ['view' => true, 'update' => false], ], ]; ``` متدها: `mergePermissions(array $partial)` (deep-merge با cast به bool — عیناً از `DoctorSecretary.php:104-122` الگو بگیر)، `getPermissions()`، `toArray()`. **`toArray()` نباید envelope را flatten کند** — همان `{version, resources}` را برگردان تا کلاینت با دو شکل مختلف روبه‌رو نشود (ضعف فعلی سیستم منشی). Migration بساز و اجرا کن. برای هر سطر موجود `clinic_doctors` یک سطر با مجوز پیش‌فرض seed کن (در همان migration یا با یک command). ### ۲. Repository + Service `src/Clinic/Repository/ClinicDoctorPermissionRepository.php`: - `findOneFor(Clinic $clinic, Doctor $doctor): ?ClinicDoctorPermission` - `findByClinic(Clinic $clinic): array` - `getOrCreate(Clinic $clinic, Doctor $doctor): ClinicDoctorPermission` — پزشکی که قبل از این feature عضو شده، سطر ندارد؛ در اولین دسترسی با مجوز پیش‌فرض ساخته شود. ### ۳. چکر مجوز — با call site واقعی `src/Clinic/Security/ClinicDoctorPermissionChecker.php`: ```php public function can(User $user, Clinic $clinic, string $resource, string $action): bool ``` - مالک کلینیک و `ROLE_ADMIN` → همیشه `true`. - در غیر این‌صورت: پروفایل پزشکِ `$user` را بگیر، سطر مجوز را پیدا کن، `active` و `resources.$resource.$action` را برگردان. سطر نبود یا `active=false` → `false`. - یک `assert(...)` هم داشته باشد که در صورت false، `AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403)` پرتاب کند. **این کلاس باید واقعاً استفاده شود.** حداقل در endpointهای زیر آن را صدا بزن (نه فقط تعریف کن): - `GET /api/v1/clinic/doctor-list/{clinicUuid}` → `clinic_info.view` - تنظیمات نوبت‌دهی (در پرامپت دوم) → `appointment_settings.view` / `.update` اگر منبعی هنوز endpoint متناظر ندارد، آن کلید را از `DEFAULT_PERMISSIONS` حذف کن — کلید ذخیره‌شده‌ای که هرگز چک نمی‌شود، همان اشتباه سیستم منشی است. ### ۴. Endpointهای مدیریت مجوز در `src/Clinic/Controller/ClinicController.php` (یا کنترلر جدید `ClinicDoctorPermissionController` اگر تمیزتر بود): | Method | Path | دسترسی | |---|---|---| | `GET` | `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions` | مالک کلینیک یا `ROLE_ADMIN` | | `PATCH` | `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions` | مالک کلینیک یا `ROLE_ADMIN` | بدنه PATCH: `{ "permissions": { "appointments": { "cancel": false } } }` — deep-merge، نه جایگزینی کامل. `active` هم قابل تغییر باشد: `{ "active": false }`. پاسخ‌ها با `$this->success($perm->toArray())`. اگر پزشک عضو این کلینیک نیست → `404` با `ERR_NOT_FOUND_001`. بررسی دسترسی مالک: همان الگوی `assertClinicAccess()` در `src/ClinicInvitation/Controller/ClinicInvitationController.php:196-205`. ### ۵. انتشار مجوز در context در `src/Auth/Controller/AuthController.php:711-718`، برای پزشکِ غیرمالک فیلد `permissions` را اضافه کن: ```php $contexts[] = [ 'type' => 'clinic', 'db_uuid' => $clinic->getUuid(), 'name' => $clinic->getName() ?? '', 'role' => $isOwner ? 'clinic' : 'doctor', 'scope' => $isOwner ? null : 'clinic', 'doctor_uuid' => $doctor->getUuid(), 'permissions' => $isOwner ? null : $this->clinicDoctorPermRepo->getOrCreate($clinic, $doctor)->getPermissions(), ]; ``` مراقب N+1 باش: `buildAvailableContexts` روی همه کلینیک‌های پزشک حلقه می‌زند — مجوزها را با یک کوئری برای همه کلینیک‌ها بگیر و در آرایه نگاشت کن. ### ۶. `usePermissions` سمت فرانت `assets/admin/hooks/usePermissions.ts` — primitive تازه (امروز اصلاً وجود ندارد): ```ts export function usePermissions() { const context = useAuthStore(s => s.context); const primaryRole = useAuthStore(s => s.primaryRole); const can = useCallback((resource: string, action: string): boolean => { if (primaryRole === 'admin' || primaryRole === 'clinic') return true; const res = (context?.permissions as any)?.resources; if (!res) return true; // context بدون مجوز = پزشک در مطب شخصی خودش return Boolean(res?.[resource]?.[action]); }, [context, primaryRole]); return { can }; } ``` نکته مهم: نبودِ `permissions` یعنی «مطب شخصی، محدودیتی نیست» — نه «هیچ دسترسی». اگر برعکس پیاده شود، پزشک مستقل کل پنلش را از دست می‌دهد. سپس `Sidebar.buildSections` (`:52-76`) را از حالت hardcode خارج کن: به‌جای «فقط داشبورد و نوبت‌های من» برای `scope === 'clinic'`، آیتم‌ها را با `can(resource, 'view')` فیلتر کن. رفتار پیش‌فرض باید معادل امروز بماند برای مجوز پیش‌فرضِ محدود، ولی با روشن کردن یک مجوز، آیتم مربوطه ظاهر شود. ### ۷. UI مدیریت مجوز در صفحه پزشکان کلینیک - `ClinicDoctorItem` (در `ClinicDoctorsManager.tsx`) فیلد `permissions` و `permission_active` بگیرد (از `doctor-list` برگردانده شود، یا با کوئری جدا). - یک `mini-btn` سوم با `ShieldCheckIcon` بین «مشاهده پروفایل» و «جداسازی»، داخل بلوک `{!readOnly && …}`. - کامپوننت جدید `assets/admin/components/ui/DoctorPermissionsModal.tsx` — ماتریس چک‌باکس، عیناً از `SecretariesPage.tsx:82-140` الگو بگیر، با `PERMISSION_LABELS` فارسی برای شش منبع بالا و یک سوییچ «فعال/غیرفعال» برای `active`. - ذخیره با `api.patch('/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions', { permissions })` و `invalidateQueries(['clinic-doctors', clinicUuid])`. - از `Modal` موجود در `components/ui/` استفاده کن، نه modal دستی. برای هر select احتمالی از `SearchableSelect` استفاده کن، نه `