Files
clinicpro/.claude/prompt/clinic-doctor-permissions.md
hamed e4ddd38f0c 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.
2026-07-18 09:44:13 +03:30

16 KiB
Raw Permalink Blame History

مدیریت دسترسی پزشکان کلینیک (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): ?ClinicDoctorPermission
  • findByClinic(Clinic $clinic): array
  • getOrCreate(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=falsefalse.
  • یک 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.