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:
hamed
2026-07-18 09:44:13 +03:30
parent 3a23aa242e
commit e4ddd38f0c
17 changed files with 1504 additions and 44 deletions
@@ -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 جابه‌جا می‌کند.
+241
View File
@@ -0,0 +1,241 @@
# مدیریت دسترسی پزشکان کلینیک (Per-doctor permissions)
## زمینه
پس از پذیرش دعوت‌نامه، پزشک صرفاً یک سطر در جدول join `clinic_doctors` می‌گیرد و هیچ‌جا نمی‌توان تعیین کرد که این پزشک در آن کلینیک به چه بخش‌هایی دسترسی دارد. امروز نقش او hardcode است: در `buildAvailableContexts()` پزشکِ غیرمالک، context کلینیک را با `role: 'doctor'` و `scope: 'clinic'` می‌گیرد و همین `scope` باعث می‌شود Sidebar فقط داشبورد و «نوبت‌های من» را نشان دهد — بدون هیچ امکان تنظیم.
هدف: مدیر کلینیک بتواند از `/admin/settings/clinic-doctors` برای هر پزشک سطح دسترسی تعیین کند، و این دسترسی هم در بک‌اند اعمال شود هم منوی پنل را بسازد.
الگوی مرجع در پروژه: سیستم مجوز منشی (`DoctorSecretary`). عیناً همان envelope و همان الگوی UI را تکرار کن، **ولی سه ضعف آن را تکرار نکن** (در «نکات مهم» توضیح داده شده).
> این پرامپت پیش‌نیاز `clinic-appointment-settings-tabs.md` است. اول این را اجرا کن.
## مشکل / هدف
۱. جایی برای ذخیره‌ی مجوزِ «پزشک X در کلینیک Y» وجود ندارد.
۲. مجوزها به کلاینت ارسال نمی‌شوند (context پزشکِ عضو کلینیک فیلد `permissions` ندارد).
۳. هیچ primitive سمت فرانت برای gate کردن منو/صفحه بر اساس مجوز وجود ندارد (`FeatureGate` فقط اشتراک را چک می‌کند).
۴. صفحه `/admin/settings/clinic-doctors` برای هر پزشک فقط دو اکشن دارد: مشاهده پروفایل و جداسازی.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Clinic/Entity/Clinic.php:88-95` | ManyToMany `clinic_doctors` — پیوند فعلی، بدون ستون اضافی |
| `src/Secretary/Entity/DoctorSecretary.php:20-30, 104-122, 140` | الگوی مرجع: envelope مجوز، `mergePermissions()`، `toArray()` |
| `src/Secretary/Security/SecretaryPermissionChecker.php` | چکر موجود — **کد مرده، هیچ call site ندارد** |
| `src/Auth/Controller/AuthController.php:694-765` | `buildAvailableContexts()` — جایی که باید `permissions` اضافه شود |
| `src/Clinic/Controller/ClinicController.php` | `GET /api/v1/clinic/doctor-list/{clinicUuid}`، `DELETE /api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}` |
| `assets/admin/components/ClinicDoctorsManager.tsx` | UI ردیف هر پزشک — محل دکمه مجوزها |
| `assets/admin/pages/SecretariesPage.tsx:15-22, 26+, 82-140` | الگوی مرجع `PermissionsMatrix` و `PERMISSION_LABELS` |
| `assets/admin/stores/authStore.ts:4-12` | `ContextItem.permissions?: Record<string, any>` — تعریف شده ولی هیچ مصرف‌کننده‌ای ندارد |
| `assets/admin/components/layout/Sidebar.tsx:52-76` | `buildSections(primaryRole, dbUuid, scope)` — منوی پزشکِ در scope کلینیک |
| `assets/admin/App.tsx:117-128` | `RoleRoute` + `blockClinicScope` |
| `docs/api/clinic.md` | مستند API کلینیک |
## وضعیت فعلی
پیوند کلینیک↔پزشک هیچ ستون اضافی ندارد — `src/Clinic/Entity/Clinic.php:88-95`:
```php
#[ORM\ManyToMany(targetEntity: Doctor::class)]
#[ORM\JoinTable(
name: 'clinic_doctors',
joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
private Collection $doctors;
```
context پزشکِ عضو کلینیک هیچ مجوزی حمل نمی‌کند — `src/Auth/Controller/AuthController.php:711-718`:
```php
$isOwner = $clinic->getUser()->getId() === $user->getId();
$contexts[] = [
'type' => 'clinic',
'db_uuid' => $clinic->getUuid(),
'name' => $clinic->getName() ?? '',
'role' => $isOwner ? 'clinic' : 'doctor',
'scope' => $isOwner ? null : 'clinic',
'doctor_uuid' => $doctor->getUuid(),
];
```
منوی پزشکِ در scope کلینیک hardcode است — `assets/admin/components/layout/Sidebar.tsx:52-76`:
```tsx
if (primaryRole === "doctor" && scope === "clinic") {
// فقط داشبورد و «نوبت‌های من»
```
ردیف هر پزشک فقط دو اکشن دارد — `assets/admin/components/ClinicDoctorsManager.tsx`:
```tsx
<button className="mini-btn" title="مشاهده پروفایل"
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && (
<button className="mini-btn danger" title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
```
## وظایف
### ۱. Entity جدید `ClinicDoctorPermission`
**جدول `clinic_doctors` را به entity تبدیل نکن.** شش نقطه در کد به `Clinic::$doctors` (`getDoctors`/`hasDoctor`/`findByDoctor`/`isDoctorInClinic`/detach endpoint) وابسته‌اند و mapping هم‌زمانِ ManyToMany و entity روی یک جدول، schema tool را دچار تعارض می‌کند. به‌جایش یک جدول موازی بساز:
`src/Clinic/Entity/ClinicDoctorPermission.php` — جدول `clinic_doctor_permissions`:
| ستون | نوع | توضیح |
|---|---|---|
| `id` | int, auto | |
| `uuid` | string(36) unique | `Uuid::v4()->toRfc4122()` در constructor |
| `clinic_id` | ManyToOne Clinic, `nullable: false`, `onDelete: CASCADE` | |
| `doctor_id` | ManyToOne Doctor, `nullable: false`, `onDelete: CASCADE` | |
| `permission` | json | envelope `{version, resources}` |
| `active` | bool, default true | |
| `created_at` / `updated_at` | int (Unix) | |
`UniqueConstraint(['clinic_id','doctor_id'])`.
envelope پیش‌فرض — دقیقاً هم‌شکل `DoctorSecretary::DEFAULT_PERMISSIONS` ولی با منابعِ مربوط به پزشک:
```php
public const DEFAULT_PERMISSIONS = [
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true],
'appointment_settings' => ['view' => true, 'update' => true],
'patients' => ['view' => true, 'create' => true, 'update' => true, 'delete' => false],
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'services' => ['view' => true, 'update' => false],
'clinic_info' => ['view' => true, 'update' => false],
],
];
```
متدها: `mergePermissions(array $partial)` (deep-merge با cast به bool — عیناً از `DoctorSecretary.php:104-122` الگو بگیر)، `getPermissions()`، `toArray()`.
**`toArray()` نباید envelope را flatten کند** — همان `{version, resources}` را برگردان تا کلاینت با دو شکل مختلف روبه‌رو نشود (ضعف فعلی سیستم منشی).
Migration بساز و اجرا کن. برای هر سطر موجود `clinic_doctors` یک سطر با مجوز پیش‌فرض seed کن (در همان migration یا با یک command).
### ۲. Repository + Service
`src/Clinic/Repository/ClinicDoctorPermissionRepository.php`:
- `findOneFor(Clinic $clinic, Doctor $doctor): ?ClinicDoctorPermission`
- `findByClinic(Clinic $clinic): array`
- `getOrCreate(Clinic $clinic, Doctor $doctor): ClinicDoctorPermission` — پزشکی که قبل از این feature عضو شده، سطر ندارد؛ در اولین دسترسی با مجوز پیش‌فرض ساخته شود.
### ۳. چکر مجوز — با call site واقعی
`src/Clinic/Security/ClinicDoctorPermissionChecker.php`:
```php
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
```
- مالک کلینیک و `ROLE_ADMIN` → همیشه `true`.
- در غیر این‌صورت: پروفایل پزشکِ `$user` را بگیر، سطر مجوز را پیدا کن، `active` و `resources.$resource.$action` را برگردان. سطر نبود یا `active=false``false`.
- یک `assert(...)` هم داشته باشد که در صورت false، `AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403)` پرتاب کند.
**این کلاس باید واقعاً استفاده شود.** حداقل در endpointهای زیر آن را صدا بزن (نه فقط تعریف کن):
- `GET /api/v1/clinic/doctor-list/{clinicUuid}``clinic_info.view`
- تنظیمات نوبت‌دهی (در پرامپت دوم) → `appointment_settings.view` / `.update`
اگر منبعی هنوز endpoint متناظر ندارد، آن کلید را از `DEFAULT_PERMISSIONS` حذف کن — کلید ذخیره‌شده‌ای که هرگز چک نمی‌شود، همان اشتباه سیستم منشی است.
### ۴. Endpointهای مدیریت مجوز
در `src/Clinic/Controller/ClinicController.php` (یا کنترلر جدید `ClinicDoctorPermissionController` اگر تمیزتر بود):
| Method | Path | دسترسی |
|---|---|---|
| `GET` | `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions` | مالک کلینیک یا `ROLE_ADMIN` |
| `PATCH` | `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions` | مالک کلینیک یا `ROLE_ADMIN` |
بدنه PATCH: `{ "permissions": { "appointments": { "cancel": false } } }` — deep-merge، نه جایگزینی کامل. `active` هم قابل تغییر باشد: `{ "active": false }`.
پاسخ‌ها با `$this->success($perm->toArray())`. اگر پزشک عضو این کلینیک نیست → `404` با `ERR_NOT_FOUND_001`.
بررسی دسترسی مالک: همان الگوی `assertClinicAccess()` در `src/ClinicInvitation/Controller/ClinicInvitationController.php:196-205`.
### ۵. انتشار مجوز در context
در `src/Auth/Controller/AuthController.php:711-718`، برای پزشکِ غیرمالک فیلد `permissions` را اضافه کن:
```php
$contexts[] = [
'type' => 'clinic',
'db_uuid' => $clinic->getUuid(),
'name' => $clinic->getName() ?? '',
'role' => $isOwner ? 'clinic' : 'doctor',
'scope' => $isOwner ? null : 'clinic',
'doctor_uuid' => $doctor->getUuid(),
'permissions' => $isOwner ? null : $this->clinicDoctorPermRepo->getOrCreate($clinic, $doctor)->getPermissions(),
];
```
مراقب N+1 باش: `buildAvailableContexts` روی همه کلینیک‌های پزشک حلقه می‌زند — مجوزها را با یک کوئری برای همه کلینیک‌ها بگیر و در آرایه نگاشت کن.
### ۶. `usePermissions` سمت فرانت
`assets/admin/hooks/usePermissions.ts` — primitive تازه (امروز اصلاً وجود ندارد):
```ts
export function usePermissions() {
const context = useAuthStore(s => s.context);
const primaryRole = useAuthStore(s => s.primaryRole);
const can = useCallback((resource: string, action: string): boolean => {
if (primaryRole === 'admin' || primaryRole === 'clinic') return true;
const res = (context?.permissions as any)?.resources;
if (!res) return true; // context بدون مجوز = پزشک در مطب شخصی خودش
return Boolean(res?.[resource]?.[action]);
}, [context, primaryRole]);
return { can };
}
```
نکته مهم: نبودِ `permissions` یعنی «مطب شخصی، محدودیتی نیست» — نه «هیچ دسترسی». اگر برعکس پیاده شود، پزشک مستقل کل پنلش را از دست می‌دهد.
سپس `Sidebar.buildSections` (`:52-76`) را از حالت hardcode خارج کن: به‌جای «فقط داشبورد و نوبت‌های من» برای `scope === 'clinic'`، آیتم‌ها را با `can(resource, 'view')` فیلتر کن. رفتار پیش‌فرض باید معادل امروز بماند برای مجوز پیش‌فرضِ محدود، ولی با روشن کردن یک مجوز، آیتم مربوطه ظاهر شود.
### ۷. UI مدیریت مجوز در صفحه پزشکان کلینیک
- `ClinicDoctorItem` (در `ClinicDoctorsManager.tsx`) فیلد `permissions` و `permission_active` بگیرد (از `doctor-list` برگردانده شود، یا با کوئری جدا).
- یک `mini-btn` سوم با `ShieldCheckIcon` بین «مشاهده پروفایل» و «جداسازی»، داخل بلوک `{!readOnly && …}`.
- کامپوننت جدید `assets/admin/components/ui/DoctorPermissionsModal.tsx` — ماتریس چک‌باکس، عیناً از `SecretariesPage.tsx:82-140` الگو بگیر، با `PERMISSION_LABELS` فارسی برای شش منبع بالا و یک سوییچ «فعال/غیرفعال» برای `active`.
- ذخیره با `api.patch('/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions', { permissions })` و `invalidateQueries(['clinic-doctors', clinicUuid])`.
- از `Modal` موجود در `components/ui/` استفاده کن، نه modal دستی. برای هر select احتمالی از `SearchableSelect` استفاده کن، نه `<select>` بومی.
### ۸. تست + مستندات
تست‌ها در `tests/Clinic/ClinicDoctorPermissionTest.php`:
- مالک کلینیک مجوز را می‌خواند و PATCH می‌کند → ۲۰۰
- PATCH فقط کلیدهای ارسالی را عوض می‌کند و بقیه دست‌نخورده می‌ماند (deep-merge)
- پزشکِ عضو نمی‌تواند مجوز خودش را عوض کند → ۴۰۳
- پزشکِ کلینیک دیگر → ۴۰۴
- `getOrCreate` برای پزشکی که قبل از feature عضو شده، سطر پیش‌فرض می‌سازد
- context خروجی `/oauth/userinfo` برای پزشکِ عضو، `permissions` دارد و برای مالک ندارد
`docs/api/clinic.md` را با دو endpoint جدید، شکل کامل envelope، و جدول کلیدها به‌روز کن.
## نکات مهم
- **سه ضعفِ سیستم منشی را تکرار نکن:** (۱) `SecretaryPermissionChecker` هیچ call site ندارد — چکر جدید باید واقعاً صدا زده شود؛ (۲) `DoctorSecretary::toArray()` envelope را flatten می‌کند ولی `available_contexts` نمی‌کند، پس کلاینت دو شکل می‌بیند — همه‌جا یک شکل بده؛ (۳) از ۲۲ فلگ منشی فقط ۲ تا واقعاً enforce می‌شود — کلید بدون enforcement اضافه نکن.
- همه controllerها از `BaseController` ارث می‌برند؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`.
- تاریخ‌ها Unix timestamp صحیح (`time()`), نه `DateTime`.
- لیست‌های admin با DQL array hydration (`->getArrayResult()`).
- پنل ادمین: paginated → `data?.data` و `data?.meta?.totalRecords`؛ تک‌آیتم → `data?.data` (ممکن است double-nested باشد).
- هر تغییر Entity ⇒ `doctrine:migrations:diff` + `migrate`.
- **مالک کلینیک هرگز نباید بتواند خودش را قفل کند** — چکر برای مالک همیشه `true` برمی‌گرداند، قبل از هر lookup.
- edge case: پزشکی که هم مالک کلینیک است هم عضو کلینیک دیگر — `resolvePrimaryRole()` (`AuthController.php:684-691`) یک نقش برنده می‌دهد، ولی مجوز باید per-context حساب شود نه per-role.
- edge case: جداسازی پزشک از کلینیک باید سطر `clinic_doctor_permissions` را هم حذف کند (`onDelete: CASCADE` روی FKها این را پوشش نمی‌دهد چون جدا از `clinic_doctors` است — در endpoint detach صریحاً حذف کن).
- CSS: از کلاس‌های موجود (`btn primary sm`، `mini-btn`، `badge`، `card`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL.
+18 -8
View File
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
import { usePermissions } from './hooks/usePermissions';
import AdminLayout from './components/layout/AdminLayout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
@@ -114,14 +115,23 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
}
function RoleRoute({ roles, blockClinicScope, children }: { roles: string[]; blockClinicScope?: boolean; children: React.ReactNode }) {
function RoleRoute({ roles, blockClinicScope, permission, children }: {
roles: string[];
blockClinicScope?: boolean;
/** [resource, action] — پزشکِ مهمان با داشتن این مجوز از blockClinicScope مستثنا می‌شود. */
permission?: [string, string];
children: React.ReactNode;
}) {
const primaryRole = useAuthStore((s) => s.primaryRole);
const context = useAuthStore((s) => s.context);
const { can } = usePermissions();
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
// پزشکِ مهمان در محیط کلینیک به ابزارهای مدیریتی دسترسی ندارد.
// پزشکِ مهمان در محیط کلینیک فقط تا جایی که کلینیک مجوز داده دسترسی دارد.
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
return <Navigate to="/admin/dashboard" replace />;
if (!permission || !can(permission[0], permission[1])) {
return <Navigate to="/admin/dashboard" replace />;
}
}
return <>{children}</>;
}
@@ -210,11 +220,11 @@ export default function App() {
<Route path="my-payments" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentsPage /></RoleRoute>} />
<Route path="my-payments/:patientUuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentDetailPage /></RoleRoute>} />
<Route path="patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientsListPage /></RoleRoute>} />
<Route path="patients/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientRecordFormPage /></RoleRoute>} />
<Route path="patients/:uuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientRecordFormPage /></RoleRoute>} />
<Route path="patients/:uuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><PatientDetailPage /></RoleRoute>} />
<Route path="patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><NewSessionPage /></RoleRoute>} />
<Route path="patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientsListPage /></RoleRoute>} />
<Route path="patients/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'create']}><PatientRecordFormPage /></RoleRoute>} />
<Route path="patients/:uuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'update']}><PatientRecordFormPage /></RoleRoute>} />
<Route path="patients/:uuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientDetailPage /></RoleRoute>} />
<Route path="patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'create']}><NewSessionPage /></RoleRoute>} />
<Route path="my-patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><NewSessionPage /></RoleRoute>} />
<Route path="patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
<Route path="my-patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon,
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
@@ -10,6 +10,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from './ui/ConfirmDialog';
import InviteDoctorModal from './ui/InviteDoctorModal';
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
const HUES_LIST = [256, 205, 162, 295, 272];
@@ -58,6 +59,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
const [inviteOpen, setInviteOpen] = useState(false);
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
@@ -166,13 +168,22 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && (
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
<>
<button
className="mini-btn"
title="مدیریت دسترسی‌ها"
onClick={() => setPermissionsFor(doc)}
>
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
</>
)}
</div>
</div>
@@ -274,6 +285,16 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
onCancel={() => setDetachDoctorConfirm(null)}
/>
{/* Per-doctor clinic permissions */}
{permissionsFor && (
<DoctorPermissionsModal
clinicUuid={clinicUuid}
doctorUuid={permissionsFor.uuid}
doctorName={permissionsFor.name}
onClose={() => setPermissionsFor(null)}
/>
)}
{/* Invite doctor modal */}
{inviteOpen && clinicUuid && (
<InviteDoctorModal
+26 -20
View File
@@ -29,6 +29,7 @@ import {
import { useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { usePermissions } from "../../hooks/usePermissions";
import { useAuthStore } from "../../stores/authStore";
import { useUiStore } from "../../stores/uiStore";
@@ -53,28 +54,32 @@ function buildSections(
primaryRole: string | null,
dbUuid: string | null,
scope: string | null,
can: (resource: string, action: string) => boolean,
): Section[] {
// پزشکِ مهمان در محیط کلینیک (scope=clinic): فقط داشبورد و نوبت‌های خودش؛
// ابزارهای مدیریتی مطب/کلینیک نمایش داده نمی‌شوند.
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
// برایش تعیین کرده ساخته می‌شود، نه به‌صورت hardcode.
if (primaryRole === "doctor" && scope === "clinic") {
return [
{
label: "عمومی",
items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
{
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌های من",
children: APPOINTMENTS_CHILDREN,
},
],
},
const items: SectionItem[] = [
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
];
if (can("appointments", "view")) {
items.push({
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌های من",
children: APPOINTMENTS_CHILDREN,
});
}
if (can("patients", "view")) {
items.push({
to: "/admin/patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
});
}
return [{ label: "عمومی", items }];
}
if (primaryRole === "admin") {
@@ -625,7 +630,8 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
const { hasFeature } = useSubscription();
const navigate = useNavigate();
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null);
const { can } = usePermissions();
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null, can);
const initials = (userName ?? "U").charAt(0).toUpperCase();
return (
@@ -0,0 +1,177 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import Modal from './Modal';
/** envelope کامل — همان چیزی که بک‌اند برمی‌گرداند، بدون flatten. */
export interface PermissionEnvelope {
version: number;
resources: Record<string, Record<string, boolean>>;
}
export interface ClinicDoctorPermissionPayload {
uuid: string;
clinic_uuid: string;
doctor_uuid: string;
doctor_name: string;
active: boolean;
permissions: PermissionEnvelope;
}
const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, string> }> = {
appointments: {
label: 'نوبت‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' },
},
appointment_settings: {
label: 'تنظیمات نوبت‌دهی',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
patients: {
label: 'پرونده بیماران',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
payments: {
label: 'پرداخت‌ها',
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
},
services: {
label: 'خدمات',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
clinic_info: {
label: 'اطلاعات کلینیک',
actions: { view: 'مشاهده', update: 'ویرایش' },
},
};
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorName, onClose }: {
clinicUuid: string;
doctorUuid: string;
doctorName: string;
onClose: () => void;
}) {
const qc = useQueryClient();
const [resources, setResources] = useState<PermissionEnvelope['resources']>({});
const [active, setActive] = useState(true);
const permQ = useQuery({
queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid],
queryFn: () => api.get<ApiResponse<ClinicDoctorPermissionPayload>>(
`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions`,
),
});
useEffect(() => {
const payload = permQ.data?.data;
if (!payload) return;
setResources(payload.permissions?.resources ?? {});
setActive(payload.active);
}, [permQ.data]);
const saveMut = useMutation({
mutationFn: () => api.patch<ApiResponse<ClinicDoctorPermissionPayload>>(
`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions`,
{ permissions: { resources }, active },
),
onSuccess: () => {
toast.success('دسترسی‌های پزشک ذخیره شد');
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid] });
qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] });
onClose();
},
onError: (e: Error) => toast.error(e.message),
});
const toggle = (resource: string, action: string) => {
setResources(prev => ({
...prev,
[resource]: { ...prev[resource], [action]: !prev[resource]?.[action] },
}));
};
return (
<Modal
open
size="lg"
title={`دسترسی‌های ${doctorName}`}
onClose={onClose}
footer={
<>
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
<button
className="btn primary sm"
disabled={saveMut.isPending || permQ.isLoading}
onClick={() => saveMut.mutate()}
>
ذخیره
</button>
</>
}
>
{permQ.isLoading ? (
<p className="muted">در حال بارگذاری...</p>
) : (
<>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<input
type="checkbox"
checked={active}
onChange={() => setActive(v => !v)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
/>
<span>دسترسی این پزشک به کلینیک فعال باشد</span>
</label>
<div style={{ overflowX: 'auto' }}>
<table className="t">
<thead>
<tr>
<th>بخش</th>
{ACTION_HEADERS.map(h => (
<th key={h} style={{ textAlign: 'center', fontSize: 12 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{Object.keys(RESOURCE_LABELS).map(resource => {
const config = RESOURCE_LABELS[resource];
return (
<tr key={resource}>
<td><b>{config.label}</b></td>
{ACTION_COLUMNS.map(action => {
if (!config.actions[action]) {
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}></td>;
}
return (
<td key={action} style={{ textAlign: 'center' }}>
<input
type="checkbox"
disabled={!active}
checked={resources[resource]?.[action] ?? false}
onChange={() => toggle(resource, action)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
<p className="muted" style={{ fontSize: 12, marginTop: 12 }}>
این دسترسیها فقط داخل همین کلینیک اعمال میشوند؛ مطب شخصی پزشک تحت تأثیر قرار نمیگیرد.
</p>
</>
)}
</Modal>
);
}
+29
View File
@@ -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
View File
@@ -287,8 +287,20 @@ Authorization: Bearer <token>
"type": "clinic",
"db_uuid": "clinic-uuid-...",
"name": "کلینیک سلامت",
"role": "clinic",
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966"
"role": "doctor",
"scope": "clinic",
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
"permissions": {
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
"appointment_settings": { "view": true, "update": true },
"patients": { "view": true, "create": true, "update": true, "delete": false },
"payments": { "view": true, "create": false, "update": false, "delete": false },
"services": { "view": true, "update": false },
"clinic_info": { "view": true, "update": false }
}
}
}
]
}
@@ -304,6 +316,18 @@ Authorization: Bearer <token>
| `context` | object\|null | context فعال انتخاب‌شده |
| `available_contexts` | array | همه محیط‌های کاری قابل انتخاب |
**فیلد `permissions` در هر context:**
| حالت context | مقدار `permissions` |
|---|---|
| مطب شخصی پزشک (`type: doctor`، `role: doctor`) | `null` — محیط خودش، محدودیتی ندارد |
| مالک کلینیک (`role: clinic`) | `null` — مالک هرگز محدود نمی‌شود |
| پزشکِ عضو کلینیک (`role: doctor`، `scope: clinic`) | envelope کامل `{version, resources}` از `clinic_doctor_permissions` |
| پزشکِ عضوی که دسترسی‌اش غیرفعال شده | `{version: 1, resources: {}}` — یعنی هیچ دسترسی |
| منشی (`role: secretary`) | envelope کامل از `doctor_secretaries` |
نکتهٔ مهم برای کلاینت: **نبودِ `permissions` (یا `null`) یعنی «بدون محدودیت»، نه «بدون دسترسی».** ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در `docs/api/clinic.md` → بخش *Clinic Doctor Permissions* آمده است.
**قانون `primary_role`** (اولویت‌بندی):
- `ROLE_ADMIN``"admin"`
- `ROLE_CLINIC``"clinic"`
+118 -2
View File
@@ -159,7 +159,7 @@ Get clinic detail.
Update a clinic.
**Permission:** `AUTH` must be the clinic owner or `ROLE_ADMIN`
**Permission:** `AUTH` — the clinic owner, `ROLE_ADMIN`, or a member doctor holding `clinic_info.update` (see **Clinic Doctor Permissions**)
### Path Parameters
| Param | Type | Description |
@@ -314,7 +314,7 @@ Get doctors associated with a clinic.
## DELETE `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}`
Detach a doctor from a clinic. This removes the clinic↔doctor link only (the `clinic_doctors` association); it does **not** delete the doctor or change the doctor's own `active` appointment flag.
Detach a doctor from a clinic. This removes the clinic↔doctor link (the `clinic_doctors` association) and the doctor's `clinic_doctor_permissions` row; it does **not** delete the doctor or change the doctor's own `active` appointment flag.
**Permission:** `AUTH` — the caller must be `ROLE_ADMIN` **or** the owner of this clinic (`ROLE_CLINIC` whose user owns `clinicUuid`). Any other authenticated user gets `403`.
@@ -338,6 +338,122 @@ Detach a doctor from a clinic. This removes the clinic↔doctor link only (the `
---
## Clinic Doctor Permissions
Each doctor attached to a clinic has a permission envelope scoped to **that clinic only** — the doctor's own practice is never affected. Rows live in `clinic_doctor_permissions` (one per clinic+doctor) and are created lazily with defaults for doctors who joined before this feature existed.
The envelope is always returned in full (`{version, resources}`); it is never flattened.
```json
{
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
"appointment_settings": { "view": true, "update": true },
"patients": { "view": true, "create": true, "update": true, "delete": false },
"payments": { "view": true, "create": false, "update": false, "delete": false },
"services": { "view": true, "update": false },
"clinic_info": { "view": true, "update": false }
}
}
```
`active: false` revokes everything at once regardless of the individual flags. The clinic owner and `ROLE_ADMIN` bypass all checks and can never be locked out.
Unknown resources and unknown actions in a PATCH body are silently ignored, so a client cannot invent permission keys.
---
## GET `/api/v1/admin/clinic/{clinicUuid}/doctor-permissions`
List the permission rows of every doctor in the clinic.
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "ce200cde-826d-11f1-b923-c282b864cdcc",
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
"doctor_uuid": "bcabb3a8-cae3-45ec-876c-548f9c1e1569",
"doctor_name": "دکتر تست",
"active": true,
"permissions": { "version": 1, "resources": { "...": {} } },
"created_at": 1784352916,
"updated_at": 1784352916
}
]
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
---
## GET `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions`
Read one doctor's permissions. Creates the row with defaults if it does not exist yet.
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `clinicUuid` | string (UUID) | Clinic UUID |
| `doctorUuid` | string (UUID) | Doctor UUID — must already be attached to this clinic |
### Response `200`
Single permission object (same shape as one item of the list above).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found, or doctor not attached to this clinic |
---
## PATCH `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions`
Update one doctor's permissions. **Deep merge** — only the resources/actions present in the body change; everything else keeps its current value.
**Permission:** `AUTH` — clinic owner or `ROLE_ADMIN`
### Request Body
```json
{
"permissions": { "resources": { "payments": { "create": true } } },
"active": true
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `permissions` | object | ❌ | `{resources: {<resource>: {<action>: bool}}}`. The bare `{<resource>: {...}}` form is also accepted. |
| `active` | bool | ❌ | `false` revokes all access to this clinic |
### Response `200`
Updated permission object.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_ACCESS_DENIED` | 403 | Neither admin nor the clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found, or doctor not attached to this clinic |
| `ERR_VALIDATION_001` | 422 | `permissions` is not an object |
---
## POST `/file/upload/clinic_pro/clinic/field_clinic_logo`
Upload clinic logo.
+46
View File
@@ -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');
}
}
+29 -3
View File
@@ -8,6 +8,8 @@ use App\Auth\Repository\UserActiveContextRepository;
use App\Auth\Repository\UserRepository;
use App\Auth\Service\OtpService;
use App\Auth\Service\TokenService;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
@@ -37,6 +39,7 @@ class AuthController extends BaseController
private readonly RateLimiterFactory $passwordResetLimiter,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly ClinicDoctorPermissionRepository $clinicDoctorPermRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly UserPasswordHasherInterface $hasher,
@@ -703,11 +706,18 @@ class AuthController extends BaseController
'name' => 'مطب شخصی ' . $doctor->getName(),
'role' => 'doctor',
];
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
// پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک می‌گیرد تا
// فقط نوبت‌های خودش در آن کلینیک را ببیند، نه دسترسی کامل پنل کلینیک.
$memberClinics = $this->clinicRepo->findByDoctor($doctor);
$permMap = $this->clinicDoctorPermRepo->mapByClinicForDoctor(
$doctor,
array_map(fn($c) => $c->getId(), $memberClinics),
);
foreach ($memberClinics as $clinic) {
// پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک می‌گیرد و
// دسترسی‌اش را مجوزهای همان کلینیک تعیین می‌کند، نه hardcode.
// اگر همین پزشک مالک کلینیک باشد، نقش کامل clinic در بلوک مالک پایین ست می‌شود.
$isOwner = $clinic->getUser()->getId() === $user->getId();
$perm = $permMap[$clinic->getId()] ?? null;
$contexts[] = [
'type' => 'clinic',
'db_uuid' => $clinic->getUuid(),
@@ -715,6 +725,7 @@ class AuthController extends BaseController
'role' => $isOwner ? 'clinic' : 'doctor',
'scope' => $isOwner ? null : 'clinic',
'doctor_uuid' => $doctor->getUuid(),
'permissions' => $isOwner ? null : $this->contextPermissions($perm),
];
}
}
@@ -764,6 +775,21 @@ class AuthController extends BaseController
return $contexts;
}
/**
* مجوزی که به کلاینت داده می‌شود: نبودِ سطر یعنی عضویت قدیمی (پیش‌فرض)، و
* سطر غیرفعال یعنی هیچ دسترسی.
*/
private function contextPermissions(?ClinicDoctorPermission $perm): array
{
if ($perm === null) {
return ClinicDoctorPermission::DEFAULT_PERMISSIONS;
}
return $perm->isActive()
? $perm->getPermissions()
: ['version' => 1, 'resources' => []];
}
private function findContextByDbUuid(string $dbUuid, array $contexts): ?array
{
foreach ($contexts as $ctx) {
+5 -1
View File
@@ -43,6 +43,8 @@ class ClinicController extends BaseController
private readonly CityRepository $cityRepo,
private readonly UserRepository $userRepo,
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly \App\Clinic\Repository\ClinicDoctorPermissionRepository $permRepo,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly FileValidatorService $fileValidator,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly string $projectDir,
@@ -210,7 +212,8 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -377,6 +380,7 @@ class ClinicController extends BaseController
$clinic->removeDoctor($doctor);
$this->clinicRepo->save($clinic);
$this->permRepo->deleteFor($clinic, $doctor);
return $this->success(['message' => 'پزشک از کلینیک جدا شد']);
}
@@ -0,0 +1,106 @@
<?php
namespace App\Clinic\Controller;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Clinic Doctor Permissions')]
class ClinicDoctorPermissionController extends BaseController
{
public function __construct(
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicDoctorPermissionRepository $permRepo,
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor-permissions', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listPermissions(string $clinicUuid, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->resolveClinic($clinicUuid, $user);
$data = array_map(
fn(ClinicDoctorPermission $p) => $p->toArray(),
$this->permRepo->findByClinic($clinic),
);
return $this->success($data);
}
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function showPermissions(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->resolveClinic($clinicUuid, $user);
$doctor = $this->resolveMember($clinic, $doctorUuid);
return $this->success($this->permRepo->getOrCreate($clinic, $doctor)->toArray());
}
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updatePermissions(string $clinicUuid, string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->resolveClinic($clinicUuid, $user);
$doctor = $this->resolveMember($clinic, $doctorUuid);
$perm = $this->permRepo->getOrCreate($clinic, $doctor);
$body = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('permissions', $body)) {
if (!is_array($body['permissions'])) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'permissions باید آبجکت باشد', 422, 'permissions');
}
$perm->mergePermissions($body['permissions']);
}
if (array_key_exists('active', $body)) {
$perm->setActive((bool) $body['active']);
}
$this->em->flush();
return $this->success($perm->toArray());
}
private function resolveClinic(string $clinicUuid, User $user): Clinic
{
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
}
$isOwner = $clinic->getUser()->getId() === $user->getId();
if (!$user->hasRole('ROLE_ADMIN') && !$isOwner) {
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403);
}
return $clinic;
}
private function resolveMember(Clinic $clinic, string $doctorUuid): \App\Doctor\Entity\Doctor
{
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'این پزشک به کلینیک متصل نیست', 404);
}
return $doctor;
}
}
@@ -0,0 +1,132 @@
<?php
namespace App\Clinic\Entity;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Doctor\Entity\Doctor;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* سطح دسترسی یک پزشکِ عضو در یک کلینیک مشخص.
*
* جدول join «clinic_doctors» عمداً دست‌نخورده می‌ماند (شش نقطه در کد به ManyToMany
* آن وابسته‌اند)؛ این جدول موازی فقط مجوزها را نگه می‌دارد.
*/
#[ORM\Entity(repositoryClass: ClinicDoctorPermissionRepository::class)]
#[ORM\Table(name: 'clinic_doctor_permissions')]
#[ORM\UniqueConstraint(name: 'uniq_clinic_doctor_permission', columns: ['clinic_id', 'doctor_id'])]
class ClinicDoctorPermission
{
public const DEFAULT_PERMISSIONS = [
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true],
'appointment_settings' => ['view' => true, 'update' => true],
'patients' => ['view' => true, 'create' => true, 'update' => true, 'delete' => false],
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'services' => ['view' => true, 'update' => false],
'clinic_info' => ['view' => true, 'update' => false],
],
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Clinic $clinic;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Doctor $doctor;
#[ORM\Column(name: 'permission', type: 'json')]
private array $permissions;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(Clinic $clinic, Doctor $doctor)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->clinic = $clinic;
$this->doctor = $doctor;
$this->permissions = self::DEFAULT_PERMISSIONS;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getClinic(): Clinic { return $this->clinic; }
public function getDoctor(): Doctor { return $this->doctor; }
public function getPermissions(): array { return $this->permissions; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
public function can(string $resource, string $action): bool
{
if (!$this->active) {
return false;
}
return (bool) ($this->permissions['resources'][$resource][$action] ?? false);
}
/** ادغام عمقی — فقط منابع/اکشن‌هایی که ارسال شده‌اند تغییر می‌کنند. */
public function mergePermissions(array $patch): void
{
$current = $this->permissions;
$resources = $patch['resources'] ?? $patch;
foreach ($resources as $resource => $actions) {
if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) {
continue;
}
foreach ($actions as $action => $value) {
if (!array_key_exists($action, self::DEFAULT_PERMISSIONS['resources'][$resource])) {
continue;
}
$current['resources'][$resource][$action] = (bool) $value;
}
}
$this->permissions = $current;
$this->touch();
}
private function touch(): void { $this->updatedAt = time(); }
/**
* envelope کامل برگردانده می‌شود (نه flatten) تا کلاینت همه‌جا با یک شکل واحد
* روبه‌رو باشد — برخلاف DoctorSecretary::toArray که آن را تخت می‌کند.
*/
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'clinic_uuid' => $this->clinic->getUuid(),
'doctor_uuid' => $this->doctor->getUuid(),
'doctor_name' => $this->doctor->getName(),
'active' => $this->active,
'permissions' => $this->permissions,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Clinic\Repository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Entity\ClinicDoctorPermission;
use App\Doctor\Entity\Doctor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClinicDoctorPermission>
*/
class ClinicDoctorPermissionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClinicDoctorPermission::class);
}
public function findOneFor(Clinic $clinic, Doctor $doctor): ?ClinicDoctorPermission
{
return $this->findOneBy(['clinic' => $clinic, 'doctor' => $doctor]);
}
/** @return ClinicDoctorPermission[] */
public function findByClinic(Clinic $clinic): array
{
return $this->findBy(['clinic' => $clinic]);
}
/**
* پزشکانی که پیش از این قابلیت عضو شده‌اند سطر مجوز ندارند؛ در اولین دسترسی
* با مجوز پیش‌فرض ساخته می‌شود.
*/
public function getOrCreate(Clinic $clinic, Doctor $doctor): ClinicDoctorPermission
{
$perm = $this->findOneFor($clinic, $doctor);
if ($perm !== null) {
return $perm;
}
$perm = new ClinicDoctorPermission($clinic, $doctor);
$em = $this->getEntityManager();
$em->persist($perm);
$em->flush();
return $perm;
}
/**
* مجوزهای یک پزشک در چند کلینیک، کلیددار با شناسهٔ کلینیک — برای پرهیز از N+1
* هنگام ساخت available_contexts.
*
* @param int[] $clinicIds
* @return array<int, ClinicDoctorPermission>
*/
public function mapByClinicForDoctor(Doctor $doctor, array $clinicIds): array
{
if ($clinicIds === []) {
return [];
}
$rows = $this->createQueryBuilder('p')
->andWhere('p.doctor = :doctor')
->andWhere('IDENTITY(p.clinic) IN (:clinics)')
->setParameter('doctor', $doctor)
->setParameter('clinics', $clinicIds)
->getQuery()
->getResult();
$map = [];
foreach ($rows as $row) {
$map[$row->getClinic()->getId()] = $row;
}
return $map;
}
public function deleteFor(Clinic $clinic, Doctor $doctor): void
{
$perm = $this->findOneFor($clinic, $doctor);
if ($perm === null) {
return;
}
$em = $this->getEntityManager();
$em->remove($perm);
$em->flush();
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Clinic\Security;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* تصمیم‌گیرندهٔ واحد برای «این کاربر در این کلینیک اجازهٔ فلان کار را دارد؟».
*
* مالک کلینیک و ادمین همیشه مجازند — مالک هرگز نباید بتواند خودش را قفل کند.
*/
class ClinicDoctorPermissionChecker
{
public function __construct(
private readonly ClinicDoctorPermissionRepository $permRepo,
private readonly DoctorRepository $doctorRepo,
) {}
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
return true;
}
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
return false;
}
return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action);
}
public function assert(User $user, Clinic $clinic, string $resource, string $action): void
{
if (!$this->can($user, $clinic, $resource, $action)) {
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403);
}
}
}
+184
View File
@@ -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));
}
}