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.
|
||||
+18
-8
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { usePermissions } from './hooks/usePermissions';
|
||||
import AdminLayout from './components/layout/AdminLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
@@ -114,14 +115,23 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
function RoleRoute({ roles, blockClinicScope, children }: { roles: string[]; blockClinicScope?: boolean; children: React.ReactNode }) {
|
||||
function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
||||
roles: string[];
|
||||
blockClinicScope?: boolean;
|
||||
/** [resource, action] — پزشکِ مهمان با داشتن این مجوز از blockClinicScope مستثنا میشود. */
|
||||
permission?: [string, string];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||
const context = useAuthStore((s) => s.context);
|
||||
const { can } = usePermissions();
|
||||
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||||
// پزشکِ مهمان در محیط کلینیک به ابزارهای مدیریتی دسترسی ندارد.
|
||||
// پزشکِ مهمان در محیط کلینیک فقط تا جایی که کلینیک مجوز داده دسترسی دارد.
|
||||
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
if (!permission || !can(permission[0], permission[1])) {
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
}
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -210,11 +220,11 @@ export default function App() {
|
||||
<Route path="my-payments" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentsPage /></RoleRoute>} />
|
||||
<Route path="my-payments/:patientUuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentDetailPage /></RoleRoute>} />
|
||||
|
||||
<Route path="patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientsListPage /></RoleRoute>} />
|
||||
<Route path="patients/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientRecordFormPage /></RoleRoute>} />
|
||||
<Route path="patients/:uuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientRecordFormPage /></RoleRoute>} />
|
||||
<Route path="patients/:uuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientDetailPage /></RoleRoute>} />
|
||||
<Route path="patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><NewSessionPage /></RoleRoute>} />
|
||||
<Route path="patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientsListPage /></RoleRoute>} />
|
||||
<Route path="patients/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'create']}><PatientRecordFormPage /></RoleRoute>} />
|
||||
<Route path="patients/:uuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'update']}><PatientRecordFormPage /></RoleRoute>} />
|
||||
<Route path="patients/:uuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientDetailPage /></RoleRoute>} />
|
||||
<Route path="patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'create']}><NewSessionPage /></RoleRoute>} />
|
||||
<Route path="my-patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><NewSessionPage /></RoleRoute>} />
|
||||
<Route path="patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
|
||||
<Route path="my-patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon,
|
||||
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
@@ -10,6 +10,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import InviteDoctorModal from './ui/InviteDoctorModal';
|
||||
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
|
||||
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
|
||||
@@ -58,6 +59,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
||||
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
|
||||
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
|
||||
|
||||
const doctorsQ = useQuery({
|
||||
queryKey: ['clinic-doctors', clinicUuid],
|
||||
@@ -166,13 +168,22 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="جداسازی از کلینیک"
|
||||
onClick={() => setDetachDoctorConfirm(doc)}
|
||||
>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="مدیریت دسترسیها"
|
||||
onClick={() => setPermissionsFor(doc)}
|
||||
>
|
||||
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="جداسازی از کلینیک"
|
||||
onClick={() => setDetachDoctorConfirm(doc)}
|
||||
>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,6 +285,16 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
||||
onCancel={() => setDetachDoctorConfirm(null)}
|
||||
/>
|
||||
|
||||
{/* Per-doctor clinic permissions */}
|
||||
{permissionsFor && (
|
||||
<DoctorPermissionsModal
|
||||
clinicUuid={clinicUuid}
|
||||
doctorUuid={permissionsFor.uuid}
|
||||
doctorName={permissionsFor.name}
|
||||
onClose={() => setPermissionsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invite doctor modal */}
|
||||
{inviteOpen && clinicUuid && (
|
||||
<InviteDoctorModal
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { useState } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useSubscription } from "../../hooks/useSubscription";
|
||||
import { usePermissions } from "../../hooks/usePermissions";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
@@ -53,28 +54,32 @@ function buildSections(
|
||||
primaryRole: string | null,
|
||||
dbUuid: string | null,
|
||||
scope: string | null,
|
||||
can: (resource: string, action: string) => boolean,
|
||||
): Section[] {
|
||||
// پزشکِ مهمان در محیط کلینیک (scope=clinic): فقط داشبورد و نوبتهای خودش؛
|
||||
// ابزارهای مدیریتی مطب/کلینیک نمایش داده نمیشوند.
|
||||
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
|
||||
// برایش تعیین کرده ساخته میشود، نه بهصورت hardcode.
|
||||
if (primaryRole === "doctor" && scope === "clinic") {
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/dashboard",
|
||||
icon: ChartBarIcon,
|
||||
label: "داشبورد",
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای من",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
],
|
||||
},
|
||||
const items: SectionItem[] = [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
];
|
||||
if (can("appointments", "view")) {
|
||||
items.push({
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای من",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
});
|
||||
}
|
||||
if (can("patients", "view")) {
|
||||
items.push({
|
||||
to: "/admin/patients",
|
||||
icon: FolderOpenIcon,
|
||||
label: "پرونده بیماران",
|
||||
feature: "patient_records",
|
||||
});
|
||||
}
|
||||
|
||||
return [{ label: "عمومی", items }];
|
||||
}
|
||||
|
||||
if (primaryRole === "admin") {
|
||||
@@ -625,7 +630,8 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||
const { hasFeature } = useSubscription();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null);
|
||||
const { can } = usePermissions();
|
||||
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null, can);
|
||||
const initials = (userName ?? "U").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import Modal from './Modal';
|
||||
|
||||
/** envelope کامل — همان چیزی که بکاند برمیگرداند، بدون flatten. */
|
||||
export interface PermissionEnvelope {
|
||||
version: number;
|
||||
resources: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export interface ClinicDoctorPermissionPayload {
|
||||
uuid: string;
|
||||
clinic_uuid: string;
|
||||
doctor_uuid: string;
|
||||
doctor_name: string;
|
||||
active: boolean;
|
||||
permissions: PermissionEnvelope;
|
||||
}
|
||||
|
||||
const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, string> }> = {
|
||||
appointments: {
|
||||
label: 'نوبتها',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' },
|
||||
},
|
||||
appointment_settings: {
|
||||
label: 'تنظیمات نوبتدهی',
|
||||
actions: { view: 'مشاهده', update: 'ویرایش' },
|
||||
},
|
||||
patients: {
|
||||
label: 'پرونده بیماران',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
payments: {
|
||||
label: 'پرداختها',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
services: {
|
||||
label: 'خدمات',
|
||||
actions: { view: 'مشاهده', update: 'ویرایش' },
|
||||
},
|
||||
clinic_info: {
|
||||
label: 'اطلاعات کلینیک',
|
||||
actions: { view: 'مشاهده', update: 'ویرایش' },
|
||||
},
|
||||
};
|
||||
|
||||
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
||||
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
||||
|
||||
export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorName, onClose }: {
|
||||
clinicUuid: string;
|
||||
doctorUuid: string;
|
||||
doctorName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [resources, setResources] = useState<PermissionEnvelope['resources']>({});
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
const permQ = useQuery({
|
||||
queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid],
|
||||
queryFn: () => api.get<ApiResponse<ClinicDoctorPermissionPayload>>(
|
||||
`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions`,
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const payload = permQ.data?.data;
|
||||
if (!payload) return;
|
||||
setResources(payload.permissions?.resources ?? {});
|
||||
setActive(payload.active);
|
||||
}, [permQ.data]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<ClinicDoctorPermissionPayload>>(
|
||||
`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions`,
|
||||
{ permissions: { resources }, active },
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success('دسترسیهای پزشک ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid] });
|
||||
qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] });
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const toggle = (resource: string, action: string) => {
|
||||
setResources(prev => ({
|
||||
...prev,
|
||||
[resource]: { ...prev[resource], [action]: !prev[resource]?.[action] },
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
size="lg"
|
||||
title={`دسترسیهای ${doctorName}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
disabled={saveMut.isPending || permQ.isLoading}
|
||||
onClick={() => saveMut.mutate()}
|
||||
>
|
||||
ذخیره
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{permQ.isLoading ? (
|
||||
<p className="muted">در حال بارگذاری...</p>
|
||||
) : (
|
||||
<>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={() => setActive(v => !v)}
|
||||
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
|
||||
/>
|
||||
<span>دسترسی این پزشک به کلینیک فعال باشد</span>
|
||||
</label>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>بخش</th>
|
||||
{ACTION_HEADERS.map(h => (
|
||||
<th key={h} style={{ textAlign: 'center', fontSize: 12 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(RESOURCE_LABELS).map(resource => {
|
||||
const config = RESOURCE_LABELS[resource];
|
||||
return (
|
||||
<tr key={resource}>
|
||||
<td><b>{config.label}</b></td>
|
||||
{ACTION_COLUMNS.map(action => {
|
||||
if (!config.actions[action]) {
|
||||
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
||||
}
|
||||
return (
|
||||
<td key={action} style={{ textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!active}
|
||||
checked={resources[resource]?.[action] ?? false}
|
||||
onChange={() => toggle(resource, action)}
|
||||
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: 12 }}>
|
||||
این دسترسیها فقط داخل همین کلینیک اعمال میشوند؛ مطب شخصی پزشک تحت تأثیر قرار نمیگیرد.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
/**
|
||||
* دسترسی کاربر در context فعال.
|
||||
*
|
||||
* نبودِ فیلد permissions یعنی «محیط شخصی، محدودیتی نیست» — نه «هیچ دسترسی».
|
||||
* فقط پزشکِ عضو کلینیک و منشی، context دارای مجوز میگیرند.
|
||||
*/
|
||||
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') return true;
|
||||
|
||||
// مجوز per-context حساب میشود نه per-role: کاربری که مالک یک کلینیک است
|
||||
// ممکن است در کلینیک دیگری فقط عضو باشد.
|
||||
const perms = context?.permissions as { resources?: Record<string, Record<string, boolean>> } | undefined | null;
|
||||
if (!perms?.resources) return true;
|
||||
|
||||
return Boolean(perms.resources[resource]?.[action]);
|
||||
},
|
||||
[context, primaryRole],
|
||||
);
|
||||
|
||||
return { can };
|
||||
}
|
||||
+26
-2
@@ -287,8 +287,20 @@ Authorization: Bearer <token>
|
||||
"type": "clinic",
|
||||
"db_uuid": "clinic-uuid-...",
|
||||
"name": "کلینیک سلامت",
|
||||
"role": "clinic",
|
||||
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966"
|
||||
"role": "doctor",
|
||||
"scope": "clinic",
|
||||
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -304,6 +316,18 @@ Authorization: Bearer <token>
|
||||
| `context` | object\|null | context فعال انتخابشده |
|
||||
| `available_contexts` | array | همه محیطهای کاری قابل انتخاب |
|
||||
|
||||
**فیلد `permissions` در هر context:**
|
||||
|
||||
| حالت context | مقدار `permissions` |
|
||||
|---|---|
|
||||
| مطب شخصی پزشک (`type: doctor`، `role: doctor`) | `null` — محیط خودش، محدودیتی ندارد |
|
||||
| مالک کلینیک (`role: clinic`) | `null` — مالک هرگز محدود نمیشود |
|
||||
| پزشکِ عضو کلینیک (`role: doctor`، `scope: clinic`) | envelope کامل `{version, resources}` از `clinic_doctor_permissions` |
|
||||
| پزشکِ عضوی که دسترسیاش غیرفعال شده | `{version: 1, resources: {}}` — یعنی هیچ دسترسی |
|
||||
| منشی (`role: secretary`) | envelope کامل از `doctor_secretaries` |
|
||||
|
||||
نکتهٔ مهم برای کلاینت: **نبودِ `permissions` (یا `null`) یعنی «بدون محدودیت»، نه «بدون دسترسی».** ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در `docs/api/clinic.md` → بخش *Clinic Doctor Permissions* آمده است.
|
||||
|
||||
**قانون `primary_role`** (اولویتبندی):
|
||||
- `ROLE_ADMIN` → `"admin"`
|
||||
- `ROLE_CLINIC` → `"clinic"`
|
||||
|
||||
+118
-2
@@ -159,7 +159,7 @@ Get clinic detail.
|
||||
|
||||
Update a clinic.
|
||||
|
||||
**Permission:** `AUTH` — must be the clinic owner or `ROLE_ADMIN`
|
||||
**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, or a member doctor holding `clinic_info.update` (see **Clinic Doctor Permissions**)
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
@@ -314,7 +314,7 @@ Get doctors associated with a clinic.
|
||||
|
||||
## DELETE `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}`
|
||||
|
||||
Detach a doctor from a clinic. This removes the clinic↔doctor link only (the `clinic_doctors` association); it does **not** delete the doctor or change the doctor's own `active` appointment flag.
|
||||
Detach a doctor from a clinic. This removes the clinic↔doctor link (the `clinic_doctors` association) and the doctor's `clinic_doctor_permissions` row; it does **not** delete the doctor or change the doctor's own `active` appointment flag.
|
||||
|
||||
**Permission:** `AUTH` — the caller must be `ROLE_ADMIN` **or** the owner of this clinic (`ROLE_CLINIC` whose user owns `clinicUuid`). Any other authenticated user gets `403`.
|
||||
|
||||
@@ -338,6 +338,122 @@ Detach a doctor from a clinic. This removes the clinic↔doctor link only (the `
|
||||
|
||||
---
|
||||
|
||||
## Clinic Doctor Permissions
|
||||
|
||||
Each doctor attached to a clinic has a permission envelope scoped to **that clinic only** — the doctor's own practice is never affected. Rows live in `clinic_doctor_permissions` (one per clinic+doctor) and are created lazily with defaults for doctors who joined before this feature existed.
|
||||
|
||||
The envelope is always returned in full (`{version, resources}`); it is never flattened.
|
||||
|
||||
```json
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`active: false` revokes everything at once regardless of the individual flags. The clinic owner and `ROLE_ADMIN` bypass all checks and can never be locked out.
|
||||
|
||||
Unknown resources and unknown actions in a PATCH body are silently ignored, so a client cannot invent permission keys.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/admin/clinic/{clinicUuid}/doctor-permissions`
|
||||
|
||||
List the permission rows of every doctor in the clinic.
|
||||
|
||||
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "ce200cde-826d-11f1-b923-c282b864cdcc",
|
||||
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
|
||||
"doctor_uuid": "bcabb3a8-cae3-45ec-876c-548f9c1e1569",
|
||||
"doctor_name": "دکتر تست",
|
||||
"active": true,
|
||||
"permissions": { "version": 1, "resources": { "...": {} } },
|
||||
"created_at": 1784352916,
|
||||
"updated_at": 1784352916
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions`
|
||||
|
||||
Read one doctor's permissions. Creates the row with defaults if it does not exist yet.
|
||||
|
||||
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `clinicUuid` | string (UUID) | Clinic UUID |
|
||||
| `doctorUuid` | string (UUID) | Doctor UUID — must already be attached to this clinic |
|
||||
|
||||
### Response `200`
|
||||
Single permission object (same shape as one item of the list above).
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Clinic not found, or doctor not attached to this clinic |
|
||||
|
||||
---
|
||||
|
||||
## PATCH `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions`
|
||||
|
||||
Update one doctor's permissions. **Deep merge** — only the resources/actions present in the body change; everything else keeps its current value.
|
||||
|
||||
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"permissions": { "resources": { "payments": { "create": true } } },
|
||||
"active": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `permissions` | object | ❌ | `{resources: {<resource>: {<action>: bool}}}`. The bare `{<resource>: {...}}` form is also accepted. |
|
||||
| `active` | bool | ❌ | `false` revokes all access to this clinic |
|
||||
|
||||
### Response `200`
|
||||
Updated permission object.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Clinic not found, or doctor not attached to this clinic |
|
||||
| `ERR_VALIDATION_001` | 422 | `permissions` is not an object |
|
||||
|
||||
---
|
||||
|
||||
## POST `/file/upload/clinic_pro/clinic/field_clinic_logo`
|
||||
|
||||
Upload clinic logo.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Per-doctor permissions inside a clinic.
|
||||
*
|
||||
* The clinic_doctors join table is intentionally left alone; this parallel table
|
||||
* carries the permission envelope and is seeded with defaults for every doctor
|
||||
* already attached to a clinic.
|
||||
*/
|
||||
final class Version20260718055833 extends AbstractMigration
|
||||
{
|
||||
private 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}}}';
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add clinic_doctor_permissions and seed existing clinic members with default permissions';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE clinic_doctor_permissions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, permission JSON NOT NULL, active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, clinic_id INT NOT NULL, doctor_id INT NOT NULL, UNIQUE INDEX UNIQ_2816132AD17F50A6 (uuid), INDEX IDX_2816132ACC22AD4 (clinic_id), INDEX IDX_2816132A87F4FB17 (doctor_id), UNIQUE INDEX uniq_clinic_doctor_permission (clinic_id, doctor_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE clinic_doctor_permissions ADD CONSTRAINT FK_2816132ACC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE clinic_doctor_permissions ADD CONSTRAINT FK_2816132A87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE');
|
||||
|
||||
$this->addSql(
|
||||
'INSERT INTO clinic_doctor_permissions (uuid, clinic_id, doctor_id, permission, active, created_at, updated_at)
|
||||
SELECT UUID(), cd.clinic_id, cd.doctor_id, :permissions, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM clinic_doctors cd',
|
||||
['permissions' => self::DEFAULT_PERMISSIONS],
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE clinic_doctor_permissions DROP FOREIGN KEY FK_2816132ACC22AD4');
|
||||
$this->addSql('ALTER TABLE clinic_doctor_permissions DROP FOREIGN KEY FK_2816132A87F4FB17');
|
||||
$this->addSql('DROP TABLE clinic_doctor_permissions');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Auth\Service\OtpService;
|
||||
use App\Auth\Service\TokenService;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
@@ -37,6 +39,7 @@ class AuthController extends BaseController
|
||||
private readonly RateLimiterFactory $passwordResetLimiter,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly ClinicDoctorPermissionRepository $clinicDoctorPermRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
@@ -703,11 +706,18 @@ class AuthController extends BaseController
|
||||
'name' => 'مطب شخصی ' . $doctor->getName(),
|
||||
'role' => 'doctor',
|
||||
];
|
||||
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
|
||||
// پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک میگیرد تا
|
||||
// فقط نوبتهای خودش در آن کلینیک را ببیند، نه دسترسی کامل پنل کلینیک.
|
||||
$memberClinics = $this->clinicRepo->findByDoctor($doctor);
|
||||
$permMap = $this->clinicDoctorPermRepo->mapByClinicForDoctor(
|
||||
$doctor,
|
||||
array_map(fn($c) => $c->getId(), $memberClinics),
|
||||
);
|
||||
|
||||
foreach ($memberClinics as $clinic) {
|
||||
// پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک میگیرد و
|
||||
// دسترسیاش را مجوزهای همان کلینیک تعیین میکند، نه hardcode.
|
||||
// اگر همین پزشک مالک کلینیک باشد، نقش کامل clinic در بلوک مالک پایین ست میشود.
|
||||
$isOwner = $clinic->getUser()->getId() === $user->getId();
|
||||
$perm = $permMap[$clinic->getId()] ?? null;
|
||||
$contexts[] = [
|
||||
'type' => 'clinic',
|
||||
'db_uuid' => $clinic->getUuid(),
|
||||
@@ -715,6 +725,7 @@ class AuthController extends BaseController
|
||||
'role' => $isOwner ? 'clinic' : 'doctor',
|
||||
'scope' => $isOwner ? null : 'clinic',
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'permissions' => $isOwner ? null : $this->contextPermissions($perm),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -764,6 +775,21 @@ class AuthController extends BaseController
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* مجوزی که به کلاینت داده میشود: نبودِ سطر یعنی عضویت قدیمی (پیشفرض)، و
|
||||
* سطر غیرفعال یعنی هیچ دسترسی.
|
||||
*/
|
||||
private function contextPermissions(?ClinicDoctorPermission $perm): array
|
||||
{
|
||||
if ($perm === null) {
|
||||
return ClinicDoctorPermission::DEFAULT_PERMISSIONS;
|
||||
}
|
||||
|
||||
return $perm->isActive()
|
||||
? $perm->getPermissions()
|
||||
: ['version' => 1, 'resources' => []];
|
||||
}
|
||||
|
||||
private function findContextByDbUuid(string $dbUuid, array $contexts): ?array
|
||||
{
|
||||
foreach ($contexts as $ctx) {
|
||||
|
||||
@@ -43,6 +43,8 @@ class ClinicController extends BaseController
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly \App\Clinic\Repository\ClinicDoctorPermissionRepository $permRepo,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly string $projectDir,
|
||||
@@ -210,7 +212,8 @@ class ClinicController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
|
||||
if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
@@ -377,6 +380,7 @@ class ClinicController extends BaseController
|
||||
|
||||
$clinic->removeDoctor($doctor);
|
||||
$this->clinicRepo->save($clinic);
|
||||
$this->permRepo->deleteFor($clinic, $doctor);
|
||||
|
||||
return $this->success(['message' => 'پزشک از کلینیک جدا شد']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Clinic Doctor Permissions')]
|
||||
class ClinicDoctorPermissionController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicDoctorPermissionRepository $permRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor-permissions', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function listPermissions(string $clinicUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->resolveClinic($clinicUuid, $user);
|
||||
|
||||
$data = array_map(
|
||||
fn(ClinicDoctorPermission $p) => $p->toArray(),
|
||||
$this->permRepo->findByClinic($clinic),
|
||||
);
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function showPermissions(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->resolveClinic($clinicUuid, $user);
|
||||
$doctor = $this->resolveMember($clinic, $doctorUuid);
|
||||
|
||||
return $this->success($this->permRepo->getOrCreate($clinic, $doctor)->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function updatePermissions(string $clinicUuid, string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->resolveClinic($clinicUuid, $user);
|
||||
$doctor = $this->resolveMember($clinic, $doctorUuid);
|
||||
$perm = $this->permRepo->getOrCreate($clinic, $doctor);
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (array_key_exists('permissions', $body)) {
|
||||
if (!is_array($body['permissions'])) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'permissions باید آبجکت باشد', 422, 'permissions');
|
||||
}
|
||||
$perm->mergePermissions($body['permissions']);
|
||||
}
|
||||
|
||||
if (array_key_exists('active', $body)) {
|
||||
$perm->setActive((bool) $body['active']);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success($perm->toArray());
|
||||
}
|
||||
|
||||
private function resolveClinic(string $clinicUuid, User $user): Clinic
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$isOwner = $clinic->getUser()->getId() === $user->getId();
|
||||
if (!$user->hasRole('ROLE_ADMIN') && !$isOwner) {
|
||||
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403);
|
||||
}
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
private function resolveMember(Clinic $clinic, string $doctorUuid): \App\Doctor\Entity\Doctor
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'این پزشک به کلینیک متصل نیست', 404);
|
||||
}
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Entity;
|
||||
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* سطح دسترسی یک پزشکِ عضو در یک کلینیک مشخص.
|
||||
*
|
||||
* جدول join «clinic_doctors» عمداً دستنخورده میماند (شش نقطه در کد به ManyToMany
|
||||
* آن وابستهاند)؛ این جدول موازی فقط مجوزها را نگه میدارد.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ClinicDoctorPermissionRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_doctor_permissions')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_clinic_doctor_permission', columns: ['clinic_id', 'doctor_id'])]
|
||||
class ClinicDoctorPermission
|
||||
{
|
||||
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],
|
||||
],
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Clinic $clinic;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(name: 'permission', type: 'json')]
|
||||
private array $permissions;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Clinic $clinic, Doctor $doctor)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->clinic = $clinic;
|
||||
$this->doctor = $doctor;
|
||||
$this->permissions = self::DEFAULT_PERMISSIONS;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getClinic(): Clinic { return $this->clinic; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getPermissions(): array { return $this->permissions; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
|
||||
public function can(string $resource, string $action): bool
|
||||
{
|
||||
if (!$this->active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($this->permissions['resources'][$resource][$action] ?? false);
|
||||
}
|
||||
|
||||
/** ادغام عمقی — فقط منابع/اکشنهایی که ارسال شدهاند تغییر میکنند. */
|
||||
public function mergePermissions(array $patch): void
|
||||
{
|
||||
$current = $this->permissions;
|
||||
$resources = $patch['resources'] ?? $patch;
|
||||
|
||||
foreach ($resources as $resource => $actions) {
|
||||
if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($actions as $action => $value) {
|
||||
if (!array_key_exists($action, self::DEFAULT_PERMISSIONS['resources'][$resource])) {
|
||||
continue;
|
||||
}
|
||||
$current['resources'][$resource][$action] = (bool) $value;
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissions = $current;
|
||||
$this->touch();
|
||||
}
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
/**
|
||||
* envelope کامل برگردانده میشود (نه flatten) تا کلاینت همهجا با یک شکل واحد
|
||||
* روبهرو باشد — برخلاف DoctorSecretary::toArray که آن را تخت میکند.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'clinic_uuid' => $this->clinic->getUuid(),
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'doctor_name' => $this->doctor->getName(),
|
||||
'active' => $this->active,
|
||||
'permissions' => $this->permissions,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Repository;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClinicDoctorPermission>
|
||||
*/
|
||||
class ClinicDoctorPermissionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClinicDoctorPermission::class);
|
||||
}
|
||||
|
||||
public function findOneFor(Clinic $clinic, Doctor $doctor): ?ClinicDoctorPermission
|
||||
{
|
||||
return $this->findOneBy(['clinic' => $clinic, 'doctor' => $doctor]);
|
||||
}
|
||||
|
||||
/** @return ClinicDoctorPermission[] */
|
||||
public function findByClinic(Clinic $clinic): array
|
||||
{
|
||||
return $this->findBy(['clinic' => $clinic]);
|
||||
}
|
||||
|
||||
/**
|
||||
* پزشکانی که پیش از این قابلیت عضو شدهاند سطر مجوز ندارند؛ در اولین دسترسی
|
||||
* با مجوز پیشفرض ساخته میشود.
|
||||
*/
|
||||
public function getOrCreate(Clinic $clinic, Doctor $doctor): ClinicDoctorPermission
|
||||
{
|
||||
$perm = $this->findOneFor($clinic, $doctor);
|
||||
if ($perm !== null) {
|
||||
return $perm;
|
||||
}
|
||||
|
||||
$perm = new ClinicDoctorPermission($clinic, $doctor);
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($perm);
|
||||
$em->flush();
|
||||
|
||||
return $perm;
|
||||
}
|
||||
|
||||
/**
|
||||
* مجوزهای یک پزشک در چند کلینیک، کلیددار با شناسهٔ کلینیک — برای پرهیز از N+1
|
||||
* هنگام ساخت available_contexts.
|
||||
*
|
||||
* @param int[] $clinicIds
|
||||
* @return array<int, ClinicDoctorPermission>
|
||||
*/
|
||||
public function mapByClinicForDoctor(Doctor $doctor, array $clinicIds): array
|
||||
{
|
||||
if ($clinicIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('p')
|
||||
->andWhere('p.doctor = :doctor')
|
||||
->andWhere('IDENTITY(p.clinic) IN (:clinics)')
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('clinics', $clinicIds)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[$row->getClinic()->getId()] = $row;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function deleteFor(Clinic $clinic, Doctor $doctor): void
|
||||
{
|
||||
$perm = $this->findOneFor($clinic, $doctor);
|
||||
if ($perm === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->remove($perm);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* تصمیمگیرندهٔ واحد برای «این کاربر در این کلینیک اجازهٔ فلان کار را دارد؟».
|
||||
*
|
||||
* مالک کلینیک و ادمین همیشه مجازند — مالک هرگز نباید بتواند خودش را قفل کند.
|
||||
*/
|
||||
class ClinicDoctorPermissionChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicDoctorPermissionRepository $permRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
) {}
|
||||
|
||||
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action);
|
||||
}
|
||||
|
||||
public function assert(User $user, Clinic $clinic, string $resource, string $action): void
|
||||
{
|
||||
if (!$this->can($user, $clinic, $resource, $action)) {
|
||||
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Clinic;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Per-doctor permissions inside a clinic: owner-only management, deep-merge
|
||||
* semantics, lazy provisioning for pre-existing members, and context exposure.
|
||||
*/
|
||||
class ClinicDoctorPermissionTest extends ApiTestCase
|
||||
{
|
||||
private function createClinicWithDoctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک تست');
|
||||
|
||||
$docUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($docUser, 'دکتر عضو');
|
||||
$doctor->setMobileNumber($docUser->getMobileNumber());
|
||||
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic, $doctor, $docUser];
|
||||
}
|
||||
|
||||
private function permRepo(): ClinicDoctorPermissionRepository
|
||||
{
|
||||
return static::getContainer()->get(ClinicDoctorPermissionRepository::class);
|
||||
}
|
||||
|
||||
public function testOwnerReadsLazilyProvisionedDefaults(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$res = $this->authJson('GET', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions", $owner);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['active']);
|
||||
self::assertSame(
|
||||
ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources'],
|
||||
$res['data']['permissions']['resources'],
|
||||
'a member added before this feature gets defaults on first read',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchOnlyTouchesProvidedKeys(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$res = $this->authJson(
|
||||
'PATCH',
|
||||
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
||||
$owner,
|
||||
['permissions' => ['resources' => ['payments' => ['create' => true]]]],
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$resources = $res['data']['permissions']['resources'];
|
||||
self::assertTrue($resources['payments']['create']);
|
||||
self::assertFalse($resources['payments']['delete'], 'untouched actions keep their value');
|
||||
self::assertTrue($resources['appointments']['view'], 'untouched resources keep their value');
|
||||
}
|
||||
|
||||
public function testUnknownResourceAndActionAreIgnored(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$res = $this->authJson(
|
||||
'PATCH',
|
||||
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
||||
$owner,
|
||||
['permissions' => ['resources' => ['bogus' => ['view' => true], 'payments' => ['fly' => true]]]],
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertArrayNotHasKey('bogus', $res['data']['permissions']['resources']);
|
||||
self::assertArrayNotHasKey('fly', $res['data']['permissions']['resources']['payments']);
|
||||
}
|
||||
|
||||
public function testDeactivationRevokesEverything(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$this->authJson(
|
||||
'PATCH',
|
||||
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
||||
$owner,
|
||||
['active' => false],
|
||||
);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$perm = $this->permRepo()->findOneFor(
|
||||
$this->em->getRepository(Clinic::class)->find($clinic->getId()),
|
||||
$this->em->getRepository(Doctor::class)->find($doctor->getId()),
|
||||
);
|
||||
self::assertFalse($perm->isActive());
|
||||
self::assertFalse($perm->can('appointments', 'view'), 'inactive membership grants nothing');
|
||||
}
|
||||
|
||||
public function testMemberDoctorCannotEditOwnPermissions(): void
|
||||
{
|
||||
[, $clinic, $doctor, $docUser] = $this->createClinicWithDoctor();
|
||||
|
||||
$this->authJson(
|
||||
'PATCH',
|
||||
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
||||
$docUser,
|
||||
['permissions' => ['resources' => ['payments' => ['delete' => true]]]],
|
||||
);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDoctorOfAnotherClinicIsNotFound(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicWithDoctor();
|
||||
|
||||
$strangerUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$stranger = new Doctor($strangerUser, 'دکتر بیرونی');
|
||||
$this->em->persist($stranger);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$stranger->getUuid()}/permissions", $owner);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnerIsNeverRestrictedByPermissions(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$checker = static::getContainer()->get(\App\Clinic\Security\ClinicDoctorPermissionChecker::class);
|
||||
$perm = $this->permRepo()->getOrCreate($clinic, $doctor);
|
||||
$perm->setActive(false);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertTrue($checker->can($owner, $clinic, 'clinic_info', 'update'));
|
||||
}
|
||||
|
||||
public function testMemberContextCarriesPermissionsAndOwnPracticeDoesNot(): void
|
||||
{
|
||||
[, $clinic, $doctor, $docUser] = $this->createClinicWithDoctor();
|
||||
|
||||
$res = $this->authJson('GET', '/oauth/userinfo', $docUser);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$contexts = $res['data']['available_contexts'];
|
||||
$personal = array_values(array_filter($contexts, fn($c) => $c['type'] === 'doctor'));
|
||||
$member = array_values(array_filter($contexts, fn($c) => $c['type'] === 'clinic'));
|
||||
|
||||
self::assertNotEmpty($personal);
|
||||
self::assertNotEmpty($member);
|
||||
self::assertNull($personal[0]['permissions'] ?? null, 'own practice is unrestricted');
|
||||
self::assertSame(
|
||||
ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources'],
|
||||
$member[0]['permissions']['resources'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testDetachingDoctorRemovesPermissionRow(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
$this->permRepo()->getOrCreate($clinic, $doctor);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}", $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$reloadedClinic = $this->em->getRepository(Clinic::class)->find($clinic->getId());
|
||||
$reloadedDoctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
||||
self::assertNull($this->permRepo()->findOneFor($reloadedClinic, $reloadedDoctor));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user