diff --git a/.claude/prompt/clinic-appointment-settings-tabs.md b/.claude/prompt/clinic-appointment-settings-tabs.md new file mode 100644 index 00000000..fb98894e --- /dev/null +++ b/.claude/prompt/clinic-appointment-settings-tabs.md @@ -0,0 +1,203 @@ +# یکسان‌سازی تنظیمات نوبت‌دهی + تب پزشکان در پنل کلینیک + +## زمینه + +تنظیمات نوبت‌دهی امروز فقط برای پزشکِ مستقل در دسترس است. مالک کلینیک نمی‌تواند نوبت‌دهی پزشکان کلینیکش را تنظیم کند: مسیر `/admin/appointment-settings` با `RoleRoute roles={['doctor']}` بسته است، هر ۱۴ endpoint در `AppointmentSettingsController` شرط یکسانِ «پزشک == کاربر جاری یا ادمین» دارند، و هیچ ورودی منویی برای نقش `clinic` وجود ندارد. + +خبر خوب: زیرساخت تقریباً کامل است. هر ۱۴ endpoint از قبل uuid پزشک را از path یا body می‌گیرند، و کامپوننت `ScheduleSection` هم `doctorUuid` را به‌صورت prop می‌گیرد. یعنی برای «مالک کلینیک تنظیمات پزشک X را مدیریت کند» فقط سه چیز مانع است: شرط هویت در بک‌اند، گارد route، و نبود ورودی منو. + +> پیش‌نیاز: `clinic-doctor-permissions.md` (Entity و چکر مجوز از آنجا می‌آید). اول آن را اجرا کن. + +## مشکل / هدف + +۱. **پنل شخصی پزشک** باید دقیقاً مثل پزشک مستقل کار کند — هیچ تفاوتی در ساختار و امکانات. +۲. **پنل کلینیک** در «تنظیمات → نوبت‌دهی» باید برای هر پزشک یک تب داشته باشد و با انتخاب تب، تنظیمات همان پزشک را نشان دهد. +۳. **یک پیاده‌سازی واحد** — نه دو نسخه موازی. هر دو حالت باید همان کامپوننت را رندر کنند. +۴. تغییرات هر پزشک فقط روی خودش اثر بگذارد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Controller/AppointmentSettingsController.php` | هر ۱۴ endpoint تنظیمات نوبت‌دهی | +| `src/Appointment/Entity/WeeklySchedule.php` | `OneToOne` Doctor، unique روی `doctor_id` | +| `src/Appointment/Entity/DateOverride.php` | `ManyToOne` Doctor، unique روی `(doctor_id, date)` | +| `src/Appointment/Entity/Holiday.php` | `ManyToOne` Doctor | +| `assets/admin/pages/DoctorDetailPage.tsx:2058` | `ScheduleSection` — پیاده‌سازی واقعی، داخل یک فایل صفحه | +| `assets/admin/pages/DoctorDetailPage.tsx:1252, 1772, 1944` | `WeeklyScheduleTab` / `DateOverridesTab` / `HolidaysTab` | +| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه پزشک مستقل (۳۶ خط، فقط پوسته) | +| `assets/admin/components/FreeVisitPrice.tsx` | قیمت ویزیت — **بدون پارامتر پزشک، فقط JWT-scoped** | +| `assets/admin/App.tsx:232` | route `appointment-settings` با `roles={['doctor']} blockClinicScope` | +| `assets/admin/components/layout/SettingsLayout.tsx:22-35` | `SETTINGS_MENU` (منوی موبایل) | +| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx:22` | منوی دسکتاپ تنظیمات — تعریف موازی و جدا | +| `docs/api/appointment-settings.md` | مستند API | + +## وضعیت فعلی + +**شرط هویت، ۱۴ بار کپی شده** — `src/Appointment/Controller/AppointmentSettingsController.php:75-77` و مشابهش در `:123-125`، `:199-201`، `:406-408`: + +```php +if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); +} +``` + +نسخه‌های فرزند: `$schedule->getDoctor()->getUser()->getId() !== $user->getId()`، و همین برای `$override` و `$holiday`. + +`ROLE_CLINIC` در کل این کنترلر یک بار هم استفاده نشده. `ClinicRepository` تزریق شده (`:36`) ولی فقط در `availableLocations` (`:410`) برای لیست آدرس‌ها به کار می‌رود، نه برای مجوز. + +**صفحه پزشک مستقل، uuid را از authStore می‌گیرد** — `assets/admin/pages/AppointmentSettingsPage.tsx:12-14, 31`: + +```tsx +const doctorUuid = useAuthStore((s) => s.doctorUuid); +const dbUuid = useAuthStore((s) => s.dbUuid); +const uuid = doctorUuid ?? dbUuid ?? undefined; +... + +``` + +**کامپوننت اصلی از قبل پارامتری است** — `DoctorDetailPage.tsx:2062-2064` و `:1263, 1309-1310`: + +```tsx +queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`) +... +? api.patch(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta }) +: api.post('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta }); +``` + +**گارد route پزشکِ در scope کلینیک را بیرون می‌اندازد** — `assets/admin/App.tsx:117-123`: + +```tsx +if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') { + return ; +} +``` + +**ورودی منو فقط برای پزشک** — `PurchaseSubscriptionSidebar.tsx:22` و `SettingsLayout.tsx` (هر دو باید ویرایش شوند): + +```tsx +{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] }, +``` + +## وظایف + +### ۱. بک‌اند — یک helper واحد به‌جای ۱۴ شرط تکراری + +در `AppointmentSettingsController` یک متد خصوصی اضافه کن و **هر ۱۴ شرط را با آن جایگزین کن**: + +```php +private function assertDoctorAccess(Doctor $doctor, User $user): void +{ + if ($user->hasRole('ROLE_ADMIN')) { + return; + } + if ($doctor->getUser()->getId() === $user->getId()) { + return; + } + // مالک کلینیکی که این پزشک عضو آن است + $clinic = $this->clinicRepo->findByUser($user); + if ($clinic !== null && $clinic->hasDoctor($doctor)) { + return; + } + throw new AppException(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); +} +``` + +نکات: +- `Clinic::hasDoctor()` از قبل در `src/Clinic/Entity/Clinic.php:156` وجود دارد. +- برای endpointهای فرزند (`{uuid}` = uuid برنامه/override/holiday) همان helper را با `$schedule->getDoctor()` صدا بزن. +- اگر پرامپت مجوزها اجرا شده، برای پزشکِ عضو کلینیک هم مسیر بده: اگر `$user` خودش پزشکِ عضو همان کلینیک است، `ClinicDoctorPermissionChecker::can($user, $clinic, 'appointment_settings', 'update')` را چک کن. برای متدهای GET با `'view'`. +- منطق اعتبارسنجی موجود دست نخورد: `assertModeImmutable` (`:48-54`)، `serviceModeHasNoBookable` (`:56-60`)، `validateSessionsHaveLocation` (`:432-442`). + +### ۲. استخراج `ScheduleSection` به فایل مستقل + +امروز `ScheduleSection` داخل `assets/admin/pages/DoctorDetailPage.tsx` (خط ۲۰۵۸) تعریف و از آنجا export می‌شود. برای اینکه «یک ساختار واحد» واقعاً یک ماژول باشد و صفحه‌ای از صفحه دیگر import نکند: + +- `assets/admin/components/schedule/ScheduleSection.tsx` بساز و `ScheduleSection` + `WeeklyScheduleTab` (`:1252`) + `DateOverridesTab` (`:1772`) + `DateOverrideModal` (`:1632`) + `HolidaysTab` (`:1944`) + `HolidayModal` (`:1871`) و helperهای مربوطه (`BookingMeta` `:101-114`، `calcSlotCount` `:338`، `SessionEditor` `:1118`، `SlotEditor` `:1077`) را به آن منتقل کن. +- `DoctorDetailPage.tsx` و `AppointmentSettingsPage.tsx` هر دو از همان فایل import کنند. +- **هیچ تغییری در منطق نده** — این مرحله صرفاً جابه‌جایی است. بعد از انتقال، `tsc` و `yarn dev` باید بدون خطا رد شوند و رفتار صفحه پزشک مستقل عیناً همان باشد. + +### ۳. صفحه تنظیمات نوبت‌دهی کلینیک با تب پزشکان + +`assets/admin/pages/ClinicAppointmentSettingsPage.tsx`: + +```tsx +// لیست پزشکان کلینیک → تب‌ها → همان ScheduleSection با doctorUuid انتخاب‌شده +const doctorsQ = useQuery({ + queryKey: ['clinic-doctors', clinicUuid], + queryFn: () => api.get>(`/api/v1/clinic/doctor-list/${clinicUuid}`), + enabled: !!clinicUuid, +}); +const doctorList = (doctorsQ.data?.data as any)?.data ?? doctorsQ.data?.data ?? []; +const [activeUuid, setActiveUuid] = useState(null); +const selected = activeUuid ?? doctorList[0]?.uuid ?? null; + + +
{/* همان الگوی تب در ClinicDoctorsManager */} + {doctorList.map(d => ( + + ))} +
+ {selected && } +
+``` + +نکات حیاتی: +- `key={selected}` روی `ScheduleSection` **الزامی است** — بدون آن، state داخلی تب (برنامه هفتگی در حال ویرایش) بین پزشک‌ها نشت می‌کند و ممکن است تنظیمات پزشک A روی B ذخیره شود. این دقیقاً همان چیزی است که خواسته «تغییرات هر پزشک فقط روی خودش» را نقض می‌کند. +- `clinicUuid` را مثل `ClinicDoctorsPage.tsx` از context نوع `clinic` بگیر، نه مستقیم از `dbUuid` (کاربری که هم پزشک است هم مالک کلینیک، `dbUuid`‌اش ممکن است uuid پزشک باشد و همه فراخوانی‌ها ۴۰۴ شوند). +- حالت خالی: کلینیک بدون پزشک → پیام «هیچ پزشکی به این کلینیک متصل نیست» + لینک به `/admin/settings/clinic-doctors`. +- اگر تعداد پزشکان زیاد شد، تب‌ها باید افقی اسکرول شوند نه شکسته. + +### ۴. Route و منو + +`assets/admin/App.tsx`: + +```tsx +} /> +``` + +مسیر موجود `appointment-settings` (`:232`، `roles={['doctor']} blockClinicScope`) دست‌نخورده بماند — آن پنل شخصی پزشک است و باید دقیقاً مثل امروز کار کند. + +**هر دو منو** باید ورودی بگیرند (تعریفشان موازی و جداست): +- `SettingsLayout.tsx:22-35` → `SETTINGS_MENU`: یک آیتم با `roles: ['clinic']` و مقصد `/admin/settings/appointment-settings`. آیتم فعلی `roles: ['doctor']` دست‌نخورده بماند. +- `PurchaseSubscriptionSidebar.tsx:22` → همان. + +هر دو آیتم `key: 'appointment'` داشته باشند تا `active="appointment"` در `SettingsLayout` برای هر دو کار کند. + +### ۵. تکلیف `FreeVisitPrice` + +`assets/admin/components/FreeVisitPrice.tsx` روی `/api/v1/insurance-pricing` کار می‌کند و **هیچ پارامتر پزشکی نمی‌گیرد** — فقط از JWT scope می‌گیرد. اگر آن را داخل تب کلینیک رندر کنی، مالک کلینیک قیمت ویزیتِ خودش را ویرایش می‌کند نه پزشک انتخاب‌شده. یکی از دو کار را بکن و در گزارش صریح بگو کدام: + +- **الف)** به endpointهای `/api/v1/insurance-pricing` پارامتر اختیاری `doctor_uuid` اضافه کن (با همان `assertDoctorAccess`) و `FreeVisitPrice` را prop-محور کن. سازگاری عقب‌رو حفظ شود: بدون `doctor_uuid` رفتار امروز. +- **ب)** فعلاً `FreeVisitPrice` را از تب کلینیک حذف کن و در همان‌جا یادداشت بگذار. + +گزینه (الف) ارجح است چون خواسته «هیچ تفاوتی بین دو حالت نباشد» است، ولی اگر انتخاب شد باید مستند `docs/api/insurance.md` هم به‌روز شود. + +### ۶. تست + +`tests/Appointment/ClinicOwnerScheduleAccessTest.php`: +- مالک کلینیک برنامه هفتگی پزشکِ عضو را می‌خواند و PATCH می‌کند → ۲۰۰ +- مالک کلینیک روی پزشکی که عضو کلینیکش نیست → ۴۰۳ +- پزشک روی برنامه خودش → ۲۰۰ (رگرسیون: رفتار قبلی نشکند) +- پزشک روی برنامه پزشک دیگر → ۴۰۳ +- `ROLE_ADMIN` روی هر پزشکی → ۲۰۰ +- ذخیره برنامه پزشک A، `WeeklySchedule` پزشک B دست‌نخورده می‌ماند (شرط «فقط روی همان پزشک اثر بگذارد») +- همین ماتریس برای `date-override` و `holidays` + +`docs/api/appointment-settings.md` را به‌روز کن: قاعده جدید دسترسی (مالک/عضو کلینیک) در هر ۱۴ endpoint، و کد خطای ۴۰۳. + +## نکات مهم + +- `WeeklySchedule` روی `doctor_id` قید `unique` دارد (`Entity:12`) و `OneToOne` است — یعنی هر پزشک دقیقاً یک برنامه دارد و منطق upsert است. اگر تب‌ها `doctorUuid` را درست پاس ندهند، PATCH روی برنامه پزشک اشتباه می‌نشیند و داده‌ی واقعی از بین می‌رود. این پرخطرترین بخش این تسک است. +- `DateOverride` قید `unique(doctor_id, date)` دارد؛ در حالت تب، تداخل تاریخ بین پزشکان معنا ندارد ولی خطای unique را باید به پیام فارسی معنادار تبدیل کنی نه ۵۰۰. +- در booking mode سرویسی، `countBookableByEntity('doctor', $doctor->getId())` (کنترلر `:59`) hard-code روی `'doctor'` است؛ کاتالوگ سرویس خود کلینیک این شرط را برآورده نمی‌کند. اگر پزشکِ عضو کلینیک سرویس شخصی ندارد، حالت سرویسی برایش قابل فعال‌سازی نیست — این را در UI با پیام فارسی روشن کن، نه با خطای خام. +- `booking_mode` بعد از اولین ذخیره قفل می‌شود (`assertModeImmutable` `:48-54` و `modeLocked` در `WeeklyScheduleTab:1282`) — این رفتار در تب کلینیک هم باید دقیقاً همان باشد. +- هر session فعال باید `location_id` داشته باشد (`validateSessionsHaveLocation` `:432-442`)؛ آدرس‌های در دسترس از `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` می‌آید که خودش از `ClinicRepository` تغذیه می‌شود — برای پزشکِ عضو کلینیک، آدرس‌های کلینیک باید در لیست باشند. +- همه controllerها از `BaseController`؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`. +- تاریخ‌ها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`. +- Form: React Hook Form + Zod؛ server state: TanStack Query v5؛ برای هر select از `SearchableSelect` استفاده کن نه `` بومی. + +### ۸. تست + مستندات + +تست‌ها در `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. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 36daaca3..4b940649 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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 ? : <>{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
در حال بارگذاری...
; if (!roles.includes(primaryRole)) return ; - // پزشکِ مهمان در محیط کلینیک به ابزارهای مدیریتی دسترسی ندارد. + // پزشکِ مهمان در محیط کلینیک فقط تا جایی که کلینیک مجوز داده دسترسی دارد. if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') { - return ; + if (!permission || !can(permission[0], permission[1])) { + return ; + } } return <>{children}; } @@ -210,11 +220,11 @@ export default function App() { } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/ClinicDoctorsManager.tsx b/assets/admin/components/ClinicDoctorsManager.tsx index 9db74c4c..869ffde8 100644 --- a/assets/admin/components/ClinicDoctorsManager.tsx +++ b/assets/admin/components/ClinicDoctorsManager.tsx @@ -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(null); + const [permissionsFor, setPermissionsFor] = useState(null); const doctorsQ = useQuery({ queryKey: ['clinic-doctors', clinicUuid], @@ -166,13 +168,22 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: { {!readOnly && ( - + <> + + + )} @@ -274,6 +285,16 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: { onCancel={() => setDetachDoctorConfirm(null)} /> + {/* Per-doctor clinic permissions */} + {permissionsFor && ( + setPermissionsFor(null)} + /> + )} + {/* Invite doctor modal */} {inviteOpen && clinicUuid && ( 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 ( diff --git a/assets/admin/components/ui/DoctorPermissionsModal.tsx b/assets/admin/components/ui/DoctorPermissionsModal.tsx new file mode 100644 index 00000000..3dcfb8d7 --- /dev/null +++ b/assets/admin/components/ui/DoctorPermissionsModal.tsx @@ -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>; +} + +export interface ClinicDoctorPermissionPayload { + uuid: string; + clinic_uuid: string; + doctor_uuid: string; + doctor_name: string; + active: boolean; + permissions: PermissionEnvelope; +} + +const RESOURCE_LABELS: Record }> = { + 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({}); + const [active, setActive] = useState(true); + + const permQ = useQuery({ + queryKey: ['clinic-doctor-permissions', clinicUuid, doctorUuid], + queryFn: () => api.get>( + `/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>( + `/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 ( + + + + + } + > + {permQ.isLoading ? ( +

در حال بارگذاری...

+ ) : ( + <> + + +
+ + + + + {ACTION_HEADERS.map(h => ( + + ))} + + + + {Object.keys(RESOURCE_LABELS).map(resource => { + const config = RESOURCE_LABELS[resource]; + return ( + + + {ACTION_COLUMNS.map(action => { + if (!config.actions[action]) { + return ; + } + return ( + + ); + })} + + ); + })} + +
بخش{h}
{config.label} + toggle(resource, action)} + style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }} + /> +
+
+ +

+ این دسترسی‌ها فقط داخل همین کلینیک اعمال می‌شوند؛ مطب شخصی پزشک تحت تأثیر قرار نمی‌گیرد. +

+ + )} +
+ ); +} diff --git a/assets/admin/hooks/usePermissions.ts b/assets/admin/hooks/usePermissions.ts new file mode 100644 index 00000000..d6a55cf0 --- /dev/null +++ b/assets/admin/hooks/usePermissions.ts @@ -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> } | undefined | null; + if (!perms?.resources) return true; + + return Boolean(perms.resources[resource]?.[action]); + }, + [context, primaryRole], + ); + + return { can }; +} diff --git a/docs/api/auth.md b/docs/api/auth.md index 66e48f8f..fbc408cb 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -287,8 +287,20 @@ Authorization: Bearer "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 | `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"` diff --git a/docs/api/clinic.md b/docs/api/clinic.md index 007af42f..a6c85fde 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -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: {: {: bool}}}`. The bare `{: {...}}` 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. diff --git a/migrations/Version20260718055833.php b/migrations/Version20260718055833.php new file mode 100644 index 00000000..6458aecf --- /dev/null +++ b/migrations/Version20260718055833.php @@ -0,0 +1,46 @@ +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'); + } +} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 68a3de54..eef14c96 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -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) { diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index c4c1d7ac..ef295b9e 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -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' => 'پزشک از کلینیک جدا شد']); } diff --git a/src/Clinic/Controller/ClinicDoctorPermissionController.php b/src/Clinic/Controller/ClinicDoctorPermissionController.php new file mode 100644 index 00000000..4570b43f --- /dev/null +++ b/src/Clinic/Controller/ClinicDoctorPermissionController.php @@ -0,0 +1,106 @@ +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; + } +} diff --git a/src/Clinic/Entity/ClinicDoctorPermission.php b/src/Clinic/Entity/ClinicDoctorPermission.php new file mode 100644 index 00000000..797aa0c6 --- /dev/null +++ b/src/Clinic/Entity/ClinicDoctorPermission.php @@ -0,0 +1,132 @@ + 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, + ]; + } +} diff --git a/src/Clinic/Repository/ClinicDoctorPermissionRepository.php b/src/Clinic/Repository/ClinicDoctorPermissionRepository.php new file mode 100644 index 00000000..88276450 --- /dev/null +++ b/src/Clinic/Repository/ClinicDoctorPermissionRepository.php @@ -0,0 +1,91 @@ + + */ +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 + */ + 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(); + } +} diff --git a/src/Clinic/Security/ClinicDoctorPermissionChecker.php b/src/Clinic/Security/ClinicDoctorPermissionChecker.php new file mode 100644 index 00000000..0c521a5b --- /dev/null +++ b/src/Clinic/Security/ClinicDoctorPermissionChecker.php @@ -0,0 +1,44 @@ +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); + } + } +} diff --git a/tests/Clinic/ClinicDoctorPermissionTest.php b/tests/Clinic/ClinicDoctorPermissionTest.php new file mode 100644 index 00000000..a4557fff --- /dev/null +++ b/tests/Clinic/ClinicDoctorPermissionTest.php @@ -0,0 +1,184 @@ +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)); + } +}