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.
|
||||||
+17
-7
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
import { usePermissions } from './hooks/usePermissions';
|
||||||
import AdminLayout from './components/layout/AdminLayout';
|
import AdminLayout from './components/layout/AdminLayout';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
@@ -114,15 +115,24 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
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 primaryRole = useAuthStore((s) => s.primaryRole);
|
||||||
const context = useAuthStore((s) => s.context);
|
const context = useAuthStore((s) => s.context);
|
||||||
|
const { can } = usePermissions();
|
||||||
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||||||
// پزشکِ مهمان در محیط کلینیک به ابزارهای مدیریتی دسترسی ندارد.
|
// پزشکِ مهمان در محیط کلینیک فقط تا جایی که کلینیک مجوز داده دسترسی دارد.
|
||||||
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
|
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
|
||||||
|
if (!permission || !can(permission[0], permission[1])) {
|
||||||
return <Navigate to="/admin/dashboard" replace />;
|
return <Navigate to="/admin/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return <>{children}</>;
|
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" 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="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" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientsListPage /></RoleRoute>} />
|
||||||
<Route path="patients/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientRecordFormPage /></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><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><PatientDetailPage /></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><NewSessionPage /></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="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="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>} />
|
<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 { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon,
|
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
@@ -10,6 +10,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
|||||||
import { formatNumber } from '../lib/utils';
|
import { formatNumber } from '../lib/utils';
|
||||||
import ConfirmDialog from './ui/ConfirmDialog';
|
import ConfirmDialog from './ui/ConfirmDialog';
|
||||||
import InviteDoctorModal from './ui/InviteDoctorModal';
|
import InviteDoctorModal from './ui/InviteDoctorModal';
|
||||||
|
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
|
||||||
|
|
||||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
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 [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
|
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
|
||||||
|
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
|
||||||
|
|
||||||
const doctorsQ = useQuery({
|
const doctorsQ = useQuery({
|
||||||
queryKey: ['clinic-doctors', clinicUuid],
|
queryKey: ['clinic-doctors', clinicUuid],
|
||||||
@@ -166,6 +168,14 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
|||||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||||
</button>
|
</button>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="mini-btn"
|
||||||
|
title="مدیریت دسترسیها"
|
||||||
|
onClick={() => setPermissionsFor(doc)}
|
||||||
|
>
|
||||||
|
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="mini-btn danger"
|
className="mini-btn danger"
|
||||||
title="جداسازی از کلینیک"
|
title="جداسازی از کلینیک"
|
||||||
@@ -173,6 +183,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
|||||||
>
|
>
|
||||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,6 +285,16 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
|||||||
onCancel={() => setDetachDoctorConfirm(null)}
|
onCancel={() => setDetachDoctorConfirm(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Per-doctor clinic permissions */}
|
||||||
|
{permissionsFor && (
|
||||||
|
<DoctorPermissionsModal
|
||||||
|
clinicUuid={clinicUuid}
|
||||||
|
doctorUuid={permissionsFor.uuid}
|
||||||
|
doctorName={permissionsFor.name}
|
||||||
|
onClose={() => setPermissionsFor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Invite doctor modal */}
|
{/* Invite doctor modal */}
|
||||||
{inviteOpen && clinicUuid && (
|
{inviteOpen && clinicUuid && (
|
||||||
<InviteDoctorModal
|
<InviteDoctorModal
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useSubscription } from "../../hooks/useSubscription";
|
import { useSubscription } from "../../hooks/useSubscription";
|
||||||
|
import { usePermissions } from "../../hooks/usePermissions";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
|
||||||
@@ -53,28 +54,32 @@ function buildSections(
|
|||||||
primaryRole: string | null,
|
primaryRole: string | null,
|
||||||
dbUuid: string | null,
|
dbUuid: string | null,
|
||||||
scope: string | null,
|
scope: string | null,
|
||||||
|
can: (resource: string, action: string) => boolean,
|
||||||
): Section[] {
|
): Section[] {
|
||||||
// پزشکِ مهمان در محیط کلینیک (scope=clinic): فقط داشبورد و نوبتهای خودش؛
|
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
|
||||||
// ابزارهای مدیریتی مطب/کلینیک نمایش داده نمیشوند.
|
// برایش تعیین کرده ساخته میشود، نه بهصورت hardcode.
|
||||||
if (primaryRole === "doctor" && scope === "clinic") {
|
if (primaryRole === "doctor" && scope === "clinic") {
|
||||||
return [
|
const items: SectionItem[] = [
|
||||||
{
|
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||||
label: "عمومی",
|
];
|
||||||
items: [
|
if (can("appointments", "view")) {
|
||||||
{
|
items.push({
|
||||||
to: "/admin/dashboard",
|
|
||||||
icon: ChartBarIcon,
|
|
||||||
label: "داشبورد",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/admin/appointments",
|
to: "/admin/appointments",
|
||||||
icon: CalendarDaysIcon,
|
icon: CalendarDaysIcon,
|
||||||
label: "نوبتهای من",
|
label: "نوبتهای من",
|
||||||
children: APPOINTMENTS_CHILDREN,
|
children: APPOINTMENTS_CHILDREN,
|
||||||
},
|
});
|
||||||
],
|
}
|
||||||
},
|
if (can("patients", "view")) {
|
||||||
];
|
items.push({
|
||||||
|
to: "/admin/patients",
|
||||||
|
icon: FolderOpenIcon,
|
||||||
|
label: "پرونده بیماران",
|
||||||
|
feature: "patient_records",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ label: "عمومی", items }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryRole === "admin") {
|
if (primaryRole === "admin") {
|
||||||
@@ -625,7 +630,8 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
|||||||
const { hasFeature } = useSubscription();
|
const { hasFeature } = useSubscription();
|
||||||
const navigate = useNavigate();
|
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();
|
const initials = (userName ?? "U").charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
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",
|
"type": "clinic",
|
||||||
"db_uuid": "clinic-uuid-...",
|
"db_uuid": "clinic-uuid-...",
|
||||||
"name": "کلینیک سلامت",
|
"name": "کلینیک سلامت",
|
||||||
"role": "clinic",
|
"role": "doctor",
|
||||||
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966"
|
"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 فعال انتخابشده |
|
| `context` | object\|null | context فعال انتخابشده |
|
||||||
| `available_contexts` | array | همه محیطهای کاری قابل انتخاب |
|
| `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`** (اولویتبندی):
|
**قانون `primary_role`** (اولویتبندی):
|
||||||
- `ROLE_ADMIN` → `"admin"`
|
- `ROLE_ADMIN` → `"admin"`
|
||||||
- `ROLE_CLINIC` → `"clinic"`
|
- `ROLE_CLINIC` → `"clinic"`
|
||||||
|
|||||||
+118
-2
@@ -159,7 +159,7 @@ Get clinic detail.
|
|||||||
|
|
||||||
Update a clinic.
|
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
|
### Path Parameters
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
@@ -314,7 +314,7 @@ Get doctors associated with a clinic.
|
|||||||
|
|
||||||
## DELETE `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}`
|
## 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`.
|
**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`
|
## POST `/file/upload/clinic_pro/clinic/field_clinic_logo`
|
||||||
|
|
||||||
Upload 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\Repository\UserRepository;
|
||||||
use App\Auth\Service\OtpService;
|
use App\Auth\Service\OtpService;
|
||||||
use App\Auth\Service\TokenService;
|
use App\Auth\Service\TokenService;
|
||||||
|
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||||
|
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||||
use App\Clinic\Repository\ClinicRepository;
|
use App\Clinic\Repository\ClinicRepository;
|
||||||
use App\Doctor\Repository\DoctorRepository;
|
use App\Doctor\Repository\DoctorRepository;
|
||||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||||
@@ -37,6 +39,7 @@ class AuthController extends BaseController
|
|||||||
private readonly RateLimiterFactory $passwordResetLimiter,
|
private readonly RateLimiterFactory $passwordResetLimiter,
|
||||||
private readonly DoctorRepository $doctorRepo,
|
private readonly DoctorRepository $doctorRepo,
|
||||||
private readonly ClinicRepository $clinicRepo,
|
private readonly ClinicRepository $clinicRepo,
|
||||||
|
private readonly ClinicDoctorPermissionRepository $clinicDoctorPermRepo,
|
||||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||||
private readonly UserActiveContextRepository $contextRepo,
|
private readonly UserActiveContextRepository $contextRepo,
|
||||||
private readonly UserPasswordHasherInterface $hasher,
|
private readonly UserPasswordHasherInterface $hasher,
|
||||||
@@ -703,11 +706,18 @@ class AuthController extends BaseController
|
|||||||
'name' => 'مطب شخصی ' . $doctor->getName(),
|
'name' => 'مطب شخصی ' . $doctor->getName(),
|
||||||
'role' => 'doctor',
|
'role' => 'doctor',
|
||||||
];
|
];
|
||||||
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
|
$memberClinics = $this->clinicRepo->findByDoctor($doctor);
|
||||||
// پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک میگیرد تا
|
$permMap = $this->clinicDoctorPermRepo->mapByClinicForDoctor(
|
||||||
// فقط نوبتهای خودش در آن کلینیک را ببیند، نه دسترسی کامل پنل کلینیک.
|
$doctor,
|
||||||
|
array_map(fn($c) => $c->getId(), $memberClinics),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($memberClinics as $clinic) {
|
||||||
|
// پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک میگیرد و
|
||||||
|
// دسترسیاش را مجوزهای همان کلینیک تعیین میکند، نه hardcode.
|
||||||
// اگر همین پزشک مالک کلینیک باشد، نقش کامل clinic در بلوک مالک پایین ست میشود.
|
// اگر همین پزشک مالک کلینیک باشد، نقش کامل clinic در بلوک مالک پایین ست میشود.
|
||||||
$isOwner = $clinic->getUser()->getId() === $user->getId();
|
$isOwner = $clinic->getUser()->getId() === $user->getId();
|
||||||
|
$perm = $permMap[$clinic->getId()] ?? null;
|
||||||
$contexts[] = [
|
$contexts[] = [
|
||||||
'type' => 'clinic',
|
'type' => 'clinic',
|
||||||
'db_uuid' => $clinic->getUuid(),
|
'db_uuid' => $clinic->getUuid(),
|
||||||
@@ -715,6 +725,7 @@ class AuthController extends BaseController
|
|||||||
'role' => $isOwner ? 'clinic' : 'doctor',
|
'role' => $isOwner ? 'clinic' : 'doctor',
|
||||||
'scope' => $isOwner ? null : 'clinic',
|
'scope' => $isOwner ? null : 'clinic',
|
||||||
'doctor_uuid' => $doctor->getUuid(),
|
'doctor_uuid' => $doctor->getUuid(),
|
||||||
|
'permissions' => $isOwner ? null : $this->contextPermissions($perm),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -764,6 +775,21 @@ class AuthController extends BaseController
|
|||||||
return $contexts;
|
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
|
private function findContextByDbUuid(string $dbUuid, array $contexts): ?array
|
||||||
{
|
{
|
||||||
foreach ($contexts as $ctx) {
|
foreach ($contexts as $ctx) {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class ClinicController extends BaseController
|
|||||||
private readonly CityRepository $cityRepo,
|
private readonly CityRepository $cityRepo,
|
||||||
private readonly UserRepository $userRepo,
|
private readonly UserRepository $userRepo,
|
||||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||||
|
private readonly \App\Clinic\Repository\ClinicDoctorPermissionRepository $permRepo,
|
||||||
|
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||||
private readonly FileValidatorService $fileValidator,
|
private readonly FileValidatorService $fileValidator,
|
||||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||||
private readonly string $projectDir,
|
private readonly string $projectDir,
|
||||||
@@ -210,7 +212,8 @@ class ClinicController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
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);
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +380,7 @@ class ClinicController extends BaseController
|
|||||||
|
|
||||||
$clinic->removeDoctor($doctor);
|
$clinic->removeDoctor($doctor);
|
||||||
$this->clinicRepo->save($clinic);
|
$this->clinicRepo->save($clinic);
|
||||||
|
$this->permRepo->deleteFor($clinic, $doctor);
|
||||||
|
|
||||||
return $this->success(['message' => 'پزشک از کلینیک جدا شد']);
|
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