From e4ddd38f0cca70a0af1cddd8648b68b5e762e4bd Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Sat, 18 Jul 2026 09:44:13 +0330
Subject: [PATCH] feat: add per-doctor permissions management in clinics
- Implement DoctorPermissionsModal for managing doctor permissions in clinics.
- Create usePermissions hook to handle user permissions context.
- Add migration for clinic_doctor_permissions table with default permissions.
- Develop ClinicDoctorPermissionController for handling permissions API.
- Create ClinicDoctorPermission entity to manage permissions data.
- Implement ClinicDoctorPermissionRepository for database interactions.
- Add ClinicDoctorPermissionChecker for permission validation logic.
- Write tests for clinic doctor permissions functionality.
---
.../clinic-appointment-settings-tabs.md | 203 +++++++++++++++
.claude/prompt/clinic-doctor-permissions.md | 241 ++++++++++++++++++
assets/admin/App.tsx | 26 +-
.../admin/components/ClinicDoctorsManager.tsx | 37 ++-
assets/admin/components/layout/Sidebar.tsx | 46 ++--
.../components/ui/DoctorPermissionsModal.tsx | 177 +++++++++++++
assets/admin/hooks/usePermissions.ts | 29 +++
docs/api/auth.md | 28 +-
docs/api/clinic.md | 120 ++++++++-
migrations/Version20260718055833.php | 46 ++++
src/Auth/Controller/AuthController.php | 32 ++-
src/Clinic/Controller/ClinicController.php | 6 +-
.../ClinicDoctorPermissionController.php | 106 ++++++++
src/Clinic/Entity/ClinicDoctorPermission.php | 132 ++++++++++
.../ClinicDoctorPermissionRepository.php | 91 +++++++
.../ClinicDoctorPermissionChecker.php | 44 ++++
tests/Clinic/ClinicDoctorPermissionTest.php | 184 +++++++++++++
17 files changed, 1504 insertions(+), 44 deletions(-)
create mode 100644 .claude/prompt/clinic-appointment-settings-tabs.md
create mode 100644 .claude/prompt/clinic-doctor-permissions.md
create mode 100644 assets/admin/components/ui/DoctorPermissionsModal.tsx
create mode 100644 assets/admin/hooks/usePermissions.ts
create mode 100644 migrations/Version20260718055833.php
create mode 100644 src/Clinic/Controller/ClinicDoctorPermissionController.php
create mode 100644 src/Clinic/Entity/ClinicDoctorPermission.php
create mode 100644 src/Clinic/Repository/ClinicDoctorPermissionRepository.php
create mode 100644 src/Clinic/Security/ClinicDoctorPermissionChecker.php
create mode 100644 tests/Clinic/ClinicDoctorPermissionTest.php
diff --git a/.claude/prompt/clinic-appointment-settings-tabs.md b/.claude/prompt/clinic-appointment-settings-tabs.md
new file mode 100644
index 00000000..fb98894e
--- /dev/null
+++ b/.claude/prompt/clinic-appointment-settings-tabs.md
@@ -0,0 +1,203 @@
+# یکسانسازی تنظیمات نوبتدهی + تب پزشکان در پنل کلینیک
+
+## زمینه
+
+تنظیمات نوبتدهی امروز فقط برای پزشکِ مستقل در دسترس است. مالک کلینیک نمیتواند نوبتدهی پزشکان کلینیکش را تنظیم کند: مسیر `/admin/appointment-settings` با `RoleRoute roles={['doctor']}` بسته است، هر ۱۴ endpoint در `AppointmentSettingsController` شرط یکسانِ «پزشک == کاربر جاری یا ادمین» دارند، و هیچ ورودی منویی برای نقش `clinic` وجود ندارد.
+
+خبر خوب: زیرساخت تقریباً کامل است. هر ۱۴ endpoint از قبل uuid پزشک را از path یا body میگیرند، و کامپوننت `ScheduleSection` هم `doctorUuid` را بهصورت prop میگیرد. یعنی برای «مالک کلینیک تنظیمات پزشک X را مدیریت کند» فقط سه چیز مانع است: شرط هویت در بکاند، گارد route، و نبود ورودی منو.
+
+> پیشنیاز: `clinic-doctor-permissions.md` (Entity و چکر مجوز از آنجا میآید). اول آن را اجرا کن.
+
+## مشکل / هدف
+
+۱. **پنل شخصی پزشک** باید دقیقاً مثل پزشک مستقل کار کند — هیچ تفاوتی در ساختار و امکانات.
+۲. **پنل کلینیک** در «تنظیمات → نوبتدهی» باید برای هر پزشک یک تب داشته باشد و با انتخاب تب، تنظیمات همان پزشک را نشان دهد.
+۳. **یک پیادهسازی واحد** — نه دو نسخه موازی. هر دو حالت باید همان کامپوننت را رندر کنند.
+۴. تغییرات هر پزشک فقط روی خودش اثر بگذارد.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `src/Appointment/Controller/AppointmentSettingsController.php` | هر ۱۴ endpoint تنظیمات نوبتدهی |
+| `src/Appointment/Entity/WeeklySchedule.php` | `OneToOne` Doctor، unique روی `doctor_id` |
+| `src/Appointment/Entity/DateOverride.php` | `ManyToOne` Doctor، unique روی `(doctor_id, date)` |
+| `src/Appointment/Entity/Holiday.php` | `ManyToOne` Doctor |
+| `assets/admin/pages/DoctorDetailPage.tsx:2058` | `ScheduleSection` — پیادهسازی واقعی، داخل یک فایل صفحه |
+| `assets/admin/pages/DoctorDetailPage.tsx:1252, 1772, 1944` | `WeeklyScheduleTab` / `DateOverridesTab` / `HolidaysTab` |
+| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه پزشک مستقل (۳۶ خط، فقط پوسته) |
+| `assets/admin/components/FreeVisitPrice.tsx` | قیمت ویزیت — **بدون پارامتر پزشک، فقط JWT-scoped** |
+| `assets/admin/App.tsx:232` | route `appointment-settings` با `roles={['doctor']} blockClinicScope` |
+| `assets/admin/components/layout/SettingsLayout.tsx:22-35` | `SETTINGS_MENU` (منوی موبایل) |
+| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx:22` | منوی دسکتاپ تنظیمات — تعریف موازی و جدا |
+| `docs/api/appointment-settings.md` | مستند API |
+
+## وضعیت فعلی
+
+**شرط هویت، ۱۴ بار کپی شده** — `src/Appointment/Controller/AppointmentSettingsController.php:75-77` و مشابهش در `:123-125`، `:199-201`، `:406-408`:
+
+```php
+if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
+ return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
+}
+```
+
+نسخههای فرزند: `$schedule->getDoctor()->getUser()->getId() !== $user->getId()`، و همین برای `$override` و `$holiday`.
+
+`ROLE_CLINIC` در کل این کنترلر یک بار هم استفاده نشده. `ClinicRepository` تزریق شده (`:36`) ولی فقط در `availableLocations` (`:410`) برای لیست آدرسها به کار میرود، نه برای مجوز.
+
+**صفحه پزشک مستقل، uuid را از authStore میگیرد** — `assets/admin/pages/AppointmentSettingsPage.tsx:12-14, 31`:
+
+```tsx
+const doctorUuid = useAuthStore((s) => s.doctorUuid);
+const dbUuid = useAuthStore((s) => s.dbUuid);
+const uuid = doctorUuid ?? dbUuid ?? undefined;
+...
+
+```
+
+**کامپوننت اصلی از قبل پارامتری است** — `DoctorDetailPage.tsx:2062-2064` و `:1263, 1309-1310`:
+
+```tsx
+queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`)
+...
+? api.patch(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
+: api.post('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta });
+```
+
+**گارد route پزشکِ در scope کلینیک را بیرون میاندازد** — `assets/admin/App.tsx:117-123`:
+
+```tsx
+if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
+ return ;
+}
+```
+
+**ورودی منو فقط برای پزشک** — `PurchaseSubscriptionSidebar.tsx:22` و `SettingsLayout.tsx` (هر دو باید ویرایش شوند):
+
+```tsx
+{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },
+```
+
+## وظایف
+
+### ۱. بکاند — یک helper واحد بهجای ۱۴ شرط تکراری
+
+در `AppointmentSettingsController` یک متد خصوصی اضافه کن و **هر ۱۴ شرط را با آن جایگزین کن**:
+
+```php
+private function assertDoctorAccess(Doctor $doctor, User $user): void
+{
+ if ($user->hasRole('ROLE_ADMIN')) {
+ return;
+ }
+ if ($doctor->getUser()->getId() === $user->getId()) {
+ return;
+ }
+ // مالک کلینیکی که این پزشک عضو آن است
+ $clinic = $this->clinicRepo->findByUser($user);
+ if ($clinic !== null && $clinic->hasDoctor($doctor)) {
+ return;
+ }
+ throw new AppException(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
+}
+```
+
+نکات:
+- `Clinic::hasDoctor()` از قبل در `src/Clinic/Entity/Clinic.php:156` وجود دارد.
+- برای endpointهای فرزند (`{uuid}` = uuid برنامه/override/holiday) همان helper را با `$schedule->getDoctor()` صدا بزن.
+- اگر پرامپت مجوزها اجرا شده، برای پزشکِ عضو کلینیک هم مسیر بده: اگر `$user` خودش پزشکِ عضو همان کلینیک است، `ClinicDoctorPermissionChecker::can($user, $clinic, 'appointment_settings', 'update')` را چک کن. برای متدهای GET با `'view'`.
+- منطق اعتبارسنجی موجود دست نخورد: `assertModeImmutable` (`:48-54`)، `serviceModeHasNoBookable` (`:56-60`)، `validateSessionsHaveLocation` (`:432-442`).
+
+### ۲. استخراج `ScheduleSection` به فایل مستقل
+
+امروز `ScheduleSection` داخل `assets/admin/pages/DoctorDetailPage.tsx` (خط ۲۰۵۸) تعریف و از آنجا export میشود. برای اینکه «یک ساختار واحد» واقعاً یک ماژول باشد و صفحهای از صفحه دیگر import نکند:
+
+- `assets/admin/components/schedule/ScheduleSection.tsx` بساز و `ScheduleSection` + `WeeklyScheduleTab` (`:1252`) + `DateOverridesTab` (`:1772`) + `DateOverrideModal` (`:1632`) + `HolidaysTab` (`:1944`) + `HolidayModal` (`:1871`) و helperهای مربوطه (`BookingMeta` `:101-114`، `calcSlotCount` `:338`، `SessionEditor` `:1118`، `SlotEditor` `:1077`) را به آن منتقل کن.
+- `DoctorDetailPage.tsx` و `AppointmentSettingsPage.tsx` هر دو از همان فایل import کنند.
+- **هیچ تغییری در منطق نده** — این مرحله صرفاً جابهجایی است. بعد از انتقال، `tsc` و `yarn dev` باید بدون خطا رد شوند و رفتار صفحه پزشک مستقل عیناً همان باشد.
+
+### ۳. صفحه تنظیمات نوبتدهی کلینیک با تب پزشکان
+
+`assets/admin/pages/ClinicAppointmentSettingsPage.tsx`:
+
+```tsx
+// لیست پزشکان کلینیک → تبها → همان ScheduleSection با doctorUuid انتخابشده
+const doctorsQ = useQuery({
+ queryKey: ['clinic-doctors', clinicUuid],
+ queryFn: () => api.get>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
+ enabled: !!clinicUuid,
+});
+const doctorList = (doctorsQ.data?.data as any)?.data ?? doctorsQ.data?.data ?? [];
+const [activeUuid, setActiveUuid] = useState(null);
+const selected = activeUuid ?? doctorList[0]?.uuid ?? null;
+
+
+
{/* همان الگوی تب در ClinicDoctorsManager */}
+ {doctorList.map(d => (
+
+ ))}
+
+ {selected && }
+
+```
+
+نکات حیاتی:
+- `key={selected}` روی `ScheduleSection` **الزامی است** — بدون آن، state داخلی تب (برنامه هفتگی در حال ویرایش) بین پزشکها نشت میکند و ممکن است تنظیمات پزشک A روی B ذخیره شود. این دقیقاً همان چیزی است که خواسته «تغییرات هر پزشک فقط روی خودش» را نقض میکند.
+- `clinicUuid` را مثل `ClinicDoctorsPage.tsx` از context نوع `clinic` بگیر، نه مستقیم از `dbUuid` (کاربری که هم پزشک است هم مالک کلینیک، `dbUuid`اش ممکن است uuid پزشک باشد و همه فراخوانیها ۴۰۴ شوند).
+- حالت خالی: کلینیک بدون پزشک → پیام «هیچ پزشکی به این کلینیک متصل نیست» + لینک به `/admin/settings/clinic-doctors`.
+- اگر تعداد پزشکان زیاد شد، تبها باید افقی اسکرول شوند نه شکسته.
+
+### ۴. Route و منو
+
+`assets/admin/App.tsx`:
+
+```tsx
+} />
+```
+
+مسیر موجود `appointment-settings` (`:232`، `roles={['doctor']} blockClinicScope`) دستنخورده بماند — آن پنل شخصی پزشک است و باید دقیقاً مثل امروز کار کند.
+
+**هر دو منو** باید ورودی بگیرند (تعریفشان موازی و جداست):
+- `SettingsLayout.tsx:22-35` → `SETTINGS_MENU`: یک آیتم با `roles: ['clinic']` و مقصد `/admin/settings/appointment-settings`. آیتم فعلی `roles: ['doctor']` دستنخورده بماند.
+- `PurchaseSubscriptionSidebar.tsx:22` → همان.
+
+هر دو آیتم `key: 'appointment'` داشته باشند تا `active="appointment"` در `SettingsLayout` برای هر دو کار کند.
+
+### ۵. تکلیف `FreeVisitPrice`
+
+`assets/admin/components/FreeVisitPrice.tsx` روی `/api/v1/insurance-pricing` کار میکند و **هیچ پارامتر پزشکی نمیگیرد** — فقط از JWT scope میگیرد. اگر آن را داخل تب کلینیک رندر کنی، مالک کلینیک قیمت ویزیتِ خودش را ویرایش میکند نه پزشک انتخابشده. یکی از دو کار را بکن و در گزارش صریح بگو کدام:
+
+- **الف)** به endpointهای `/api/v1/insurance-pricing` پارامتر اختیاری `doctor_uuid` اضافه کن (با همان `assertDoctorAccess`) و `FreeVisitPrice` را prop-محور کن. سازگاری عقبرو حفظ شود: بدون `doctor_uuid` رفتار امروز.
+- **ب)** فعلاً `FreeVisitPrice` را از تب کلینیک حذف کن و در همانجا یادداشت بگذار.
+
+گزینه (الف) ارجح است چون خواسته «هیچ تفاوتی بین دو حالت نباشد» است، ولی اگر انتخاب شد باید مستند `docs/api/insurance.md` هم بهروز شود.
+
+### ۶. تست
+
+`tests/Appointment/ClinicOwnerScheduleAccessTest.php`:
+- مالک کلینیک برنامه هفتگی پزشکِ عضو را میخواند و PATCH میکند → ۲۰۰
+- مالک کلینیک روی پزشکی که عضو کلینیکش نیست → ۴۰۳
+- پزشک روی برنامه خودش → ۲۰۰ (رگرسیون: رفتار قبلی نشکند)
+- پزشک روی برنامه پزشک دیگر → ۴۰۳
+- `ROLE_ADMIN` روی هر پزشکی → ۲۰۰
+- ذخیره برنامه پزشک A، `WeeklySchedule` پزشک B دستنخورده میماند (شرط «فقط روی همان پزشک اثر بگذارد»)
+- همین ماتریس برای `date-override` و `holidays`
+
+`docs/api/appointment-settings.md` را بهروز کن: قاعده جدید دسترسی (مالک/عضو کلینیک) در هر ۱۴ endpoint، و کد خطای ۴۰۳.
+
+## نکات مهم
+
+- `WeeklySchedule` روی `doctor_id` قید `unique` دارد (`Entity:12`) و `OneToOne` است — یعنی هر پزشک دقیقاً یک برنامه دارد و منطق upsert است. اگر تبها `doctorUuid` را درست پاس ندهند، PATCH روی برنامه پزشک اشتباه مینشیند و دادهی واقعی از بین میرود. این پرخطرترین بخش این تسک است.
+- `DateOverride` قید `unique(doctor_id, date)` دارد؛ در حالت تب، تداخل تاریخ بین پزشکان معنا ندارد ولی خطای unique را باید به پیام فارسی معنادار تبدیل کنی نه ۵۰۰.
+- در booking mode سرویسی، `countBookableByEntity('doctor', $doctor->getId())` (کنترلر `:59`) hard-code روی `'doctor'` است؛ کاتالوگ سرویس خود کلینیک این شرط را برآورده نمیکند. اگر پزشکِ عضو کلینیک سرویس شخصی ندارد، حالت سرویسی برایش قابل فعالسازی نیست — این را در UI با پیام فارسی روشن کن، نه با خطای خام.
+- `booking_mode` بعد از اولین ذخیره قفل میشود (`assertModeImmutable` `:48-54` و `modeLocked` در `WeeklyScheduleTab:1282`) — این رفتار در تب کلینیک هم باید دقیقاً همان باشد.
+- هر session فعال باید `location_id` داشته باشد (`validateSessionsHaveLocation` `:432-442`)؛ آدرسهای در دسترس از `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` میآید که خودش از `ClinicRepository` تغذیه میشود — برای پزشکِ عضو کلینیک، آدرسهای کلینیک باید در لیست باشند.
+- همه controllerها از `BaseController`؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`.
+- تاریخها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`.
+- Form: React Hook Form + Zod؛ server state: TanStack Query v5؛ برای هر select از `SearchableSelect` استفاده کن نه `