- 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.
16 KiB
مدیریت دسترسی پزشکان کلینیک (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:
#[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:
$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:
if (primaryRole === "doctor" && scope === "clinic") {
// فقط داشبورد و «نوبتهای من»
ردیف هر پزشک فقط دو اکشن دارد — assets/admin/components/ClinicDoctorsManager.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 ولی با منابعِ مربوط به پزشک:
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): ?ClinicDoctorPermissionfindByClinic(Clinic $clinic): arraygetOrCreate(Clinic $clinic, Doctor $doctor): ClinicDoctorPermission— پزشکی که قبل از این feature عضو شده، سطر ندارد؛ در اولین دسترسی با مجوز پیشفرض ساخته شود.
۳. چکر مجوز — با call site واقعی
src/Clinic/Security/ClinicDoctorPermissionChecker.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 را اضافه کن:
$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 تازه (امروز اصلاً وجود ندارد):
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.