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.
This commit is contained in:
@@ -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;
|
||||
...
|
||||
<ScheduleSection doctorUuid={uuid} />
|
||||
```
|
||||
|
||||
**کامپوننت اصلی از قبل پارامتری است** — `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 <Navigate to="/admin/dashboard" replace />;
|
||||
}
|
||||
```
|
||||
|
||||
**ورودی منو فقط برای پزشک** — `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<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
|
||||
enabled: !!clinicUuid,
|
||||
});
|
||||
const doctorList = (doctorsQ.data?.data as any)?.data ?? doctorsQ.data?.data ?? [];
|
||||
const [activeUuid, setActiveUuid] = useState<string | null>(null);
|
||||
const selected = activeUuid ?? doctorList[0]?.uuid ?? null;
|
||||
|
||||
<SettingsLayout active="appointment">
|
||||
<div className="seg"> {/* همان الگوی تب در ClinicDoctorsManager */}
|
||||
{doctorList.map(d => (
|
||||
<button key={d.uuid} className={selected === d.uuid ? 'active' : ''} onClick={() => setActiveUuid(d.uuid)}>
|
||||
{d.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selected && <ScheduleSection key={selected} doctorUuid={selected} />}
|
||||
</SettingsLayout>
|
||||
```
|
||||
|
||||
نکات حیاتی:
|
||||
- `key={selected}` روی `ScheduleSection` **الزامی است** — بدون آن، state داخلی تب (برنامه هفتگی در حال ویرایش) بین پزشکها نشت میکند و ممکن است تنظیمات پزشک A روی B ذخیره شود. این دقیقاً همان چیزی است که خواسته «تغییرات هر پزشک فقط روی خودش» را نقض میکند.
|
||||
- `clinicUuid` را مثل `ClinicDoctorsPage.tsx` از context نوع `clinic` بگیر، نه مستقیم از `dbUuid` (کاربری که هم پزشک است هم مالک کلینیک، `dbUuid`اش ممکن است uuid پزشک باشد و همه فراخوانیها ۴۰۴ شوند).
|
||||
- حالت خالی: کلینیک بدون پزشک → پیام «هیچ پزشکی به این کلینیک متصل نیست» + لینک به `/admin/settings/clinic-doctors`.
|
||||
- اگر تعداد پزشکان زیاد شد، تبها باید افقی اسکرول شوند نه شکسته.
|
||||
|
||||
### ۴. Route و منو
|
||||
|
||||
`assets/admin/App.tsx`:
|
||||
|
||||
```tsx
|
||||
<Route path="settings/appointment-settings"
|
||||
element={<RoleRoute roles={['clinic']}><ClinicAppointmentSettingsPage /></RoleRoute>} />
|
||||
```
|
||||
|
||||
مسیر موجود `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` استفاده کن نه `<select>` بومی.
|
||||
- CSS: از کلاسهای موجود (`seg`، `card`، `btn primary sm`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL.
|
||||
- بعد از انتقال کامپوننتها حتماً `ddev exec npx tsc --noEmit` و `ddev exec yarn dev` را اجرا کن — این refactor حجم زیادی import جابهجا میکند.
|
||||
@@ -0,0 +1,241 @@
|
||||
# مدیریت دسترسی پزشکان کلینیک (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<string, any>` — تعریف شده ولی هیچ مصرفکنندهای ندارد |
|
||||
| `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
|
||||
<button className="mini-btn" title="مشاهده پروفایل"
|
||||
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}>
|
||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button className="mini-btn danger" title="جداسازی از کلینیک"
|
||||
onClick={() => setDetachDoctorConfirm(doc)}>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. 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` استفاده کن، نه `<select>` بومی.
|
||||
|
||||
### ۸. تست + مستندات
|
||||
|
||||
تستها در `tests/Clinic/ClinicDoctorPermissionTest.php`:
|
||||
- مالک کلینیک مجوز را میخواند و PATCH میکند → ۲۰۰
|
||||
- PATCH فقط کلیدهای ارسالی را عوض میکند و بقیه دستنخورده میماند (deep-merge)
|
||||
- پزشکِ عضو نمیتواند مجوز خودش را عوض کند → ۴۰۳
|
||||
- پزشکِ کلینیک دیگر → ۴۰۴
|
||||
- `getOrCreate` برای پزشکی که قبل از feature عضو شده، سطر پیشفرض میسازد
|
||||
- context خروجی `/oauth/userinfo` برای پزشکِ عضو، `permissions` دارد و برای مالک ندارد
|
||||
|
||||
`docs/api/clinic.md` را با دو endpoint جدید، شکل کامل envelope، و جدول کلیدها بهروز کن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **سه ضعفِ سیستم منشی را تکرار نکن:** (۱) `SecretaryPermissionChecker` هیچ call site ندارد — چکر جدید باید واقعاً صدا زده شود؛ (۲) `DoctorSecretary::toArray()` envelope را flatten میکند ولی `available_contexts` نمیکند، پس کلاینت دو شکل میبیند — همهجا یک شکل بده؛ (۳) از ۲۲ فلگ منشی فقط ۲ تا واقعاً enforce میشود — کلید بدون enforcement اضافه نکن.
|
||||
- همه controllerها از `BaseController` ارث میبرند؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`.
|
||||
- تاریخها Unix timestamp صحیح (`time()`), نه `DateTime`.
|
||||
- لیستهای admin با DQL array hydration (`->getArrayResult()`).
|
||||
- پنل ادمین: paginated → `data?.data` و `data?.meta?.totalRecords`؛ تکآیتم → `data?.data` (ممکن است double-nested باشد).
|
||||
- هر تغییر Entity ⇒ `doctrine:migrations:diff` + `migrate`.
|
||||
- **مالک کلینیک هرگز نباید بتواند خودش را قفل کند** — چکر برای مالک همیشه `true` برمیگرداند، قبل از هر lookup.
|
||||
- edge case: پزشکی که هم مالک کلینیک است هم عضو کلینیک دیگر — `resolvePrimaryRole()` (`AuthController.php:684-691`) یک نقش برنده میدهد، ولی مجوز باید per-context حساب شود نه per-role.
|
||||
- edge case: جداسازی پزشک از کلینیک باید سطر `clinic_doctor_permissions` را هم حذف کند (`onDelete: CASCADE` روی FKها این را پوشش نمیدهد چون جدا از `clinic_doctors` است — در endpoint detach صریحاً حذف کن).
|
||||
- CSS: از کلاسهای موجود (`btn primary sm`، `mini-btn`، `badge`، `card`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL.
|
||||
Reference in New Issue
Block a user