The list of permissionable resources was duplicated in six places that had already diverged: both permission entities, three admin UI files and the SecretaryPermissions TypeScript interface. Adding a resource meant editing all of them, so new pages borrowed an unrelated resource instead — five resource pages sat on appointment_settings.view and treatment-cases on appointments.view. PermissionCatalog is now the only place that says which resources and actions exist. Each entity keeps its own DEFAULT_PERMISSIONS, but as role policy only; a test asserts those defaults never name a resource the registry doesn't have. getPermissions() merges the stored JSON over the role defaults, so a resource added to the registry later resolves to the role default instead of silently false for every existing row. Explicitly stored values are never overwritten, and no data migration is needed. Two asymmetries fixed along the way: - ClinicDoctorPermission validated writes against its own DEFAULT_PERMISSIONS, so services.create/delete could never be stored for an invited doctor. - DoctorSecretary had no validation at all and would store any key, and it only read $patch['resources'] — the admin SecretariesPage sends a flat map, so its permission edit silently did nothing. Both entities now accept either shape and filter through the registry. New resources 'resources' and 'treatment' are registered with defaults chosen to preserve today's effective access, since both pages are currently gated on a borrowed resource. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
رجیستری واحدِ مجوزها برای منشی و پزشکِ دعوتشده
پروژه
clinicpro (backend + پنل ادمین). cross-repo نیست.
زمینه
دو نقش در یک کلینیک مجوز per-resource دارند: منشی و پزشکِ دعوتشده. هر کدام جدول مجوز خودش را دارد و هر کدام یک فهرست جدا از «منابع» در کد.
امروز آن فهرست چهار بار نوشته شده و هیچکدام از هم خبر ندارند:
| کجا | چه چیزی |
|---|---|
src/Secretary/Entity/DoctorSecretary.php:26 |
DEFAULT_PERMISSIONS — ۱۵ منبع |
src/Clinic/Entity/ClinicDoctorPermission.php:21 |
DEFAULT_PERMISSIONS — ۱۳ منبع |
assets/admin/pages/MySecretariesPage.tsx:70 |
EMPTY_PERMISSIONS + PERMISSION_SECTIONS |
assets/admin/components/ui/DoctorPermissionsModal.tsx:24 |
RESOURCE_LABELS |
نتیجهاش را میشود در خودِ کد دید.
مشکل / هدف
۱. دو فهرست backend با هم فرق دارند.
منشی این دو را دارد و پزشک ندارد: clinic_doctors، subscription.
و برای services:
// DoctorSecretary
'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
// ClinicDoctorPermission
'services' => ['view' => true, 'update' => false],
یعنی همان منبع در دو نقش دو مجموعهٔ action دارد. ClinicDoctorPermission::apply()
هر actionی را که در DEFAULT_PERMISSIONS نباشد دور میاندازد، پس create و delete
برای پزشک اصلاً قابل ذخیره نیست.
۲. صفحههای تازه، مجوزِ صفحهٔ دیگری را قرض میگیرند.
از ۸۴ route، ۳۲ تا گیت مجوز دارند. ولی پنج صفحهٔ متفاوت روی یک منبع نشستهاند:
resources · resource-types · catalog-categories · skills · resource-pools
→ همه: permission={['appointment_settings', 'view']}
treatment-cases
→ permission={['appointments', 'view']}
هیچکدام از اینها واقعاً «تنظیمات نوبتدهی» یا «نوبتها» نیستند. دلیلش روشن است: افزودن یک منبع تازه یعنی ویرایش دستیِ چهار فهرست، پس هرکس نزدیکترین منبع موجود را برداشته. مجوزها از ساختار صفحهها جدا افتادهاند.
۳. جواب سؤال «آیا میشود داینامیک باشد؟» بله است.
یک رجیستری در PHP بهعنوان منبع واحد، یک اندپوینت که آن را میدهد، و دو UI که بهجای فهرست هاردکد از همان میخوانند. صفحهٔ تازه = یک ردیف در رجیستری، نه چهار ویرایش.
⚠ دو تصمیم که باید قبل از کد روشن باشد
الف) «دقیقاً شبیه پزشک» یعنی فهرست یکی شود، نه پیشفرضها
فهرستِ منابع و actionها برای هر دو نقش یکی میشود — این خواستهٔ روشنِ کاربر است.
مقادیر پیشفرض یکی نمیشوند و نباید بشوند: پزشکِ عضو کلینیک بهطور طبیعی از منشی
دسترسی بیشتری دارد (مثلاً patients.update پیشفرضش true است و برای منشی false).
یکی کردنشان یعنی یا منشی بیش از حد باز شود یا پزشک بیجهت بسته.
پس رجیستری شکل را میدهد و هر نقش پیشفرضِ خودش را.
اگر منظور کاربر این نبوده و واقعاً میخواهد پیشفرضها هم یکی شود، قبل از پیادهسازی بپرس — این تغییر روی همهٔ منشیهای موجود اثر میگذارد.
ب) دادهٔ ذخیرهشده نباید پاک شود
هر دو جدول ستون permission از نوع json دارند و مقدارِ فعلیِ منشیها و پزشکها
داخلش است. افزودن منبع تازه به رجیستری نباید مقدار ذخیرهشده را بازنویسی کند.
قاعده: خواندن = merge رجیستری با مقدارِ ذخیرهشده؛ کلیدِ نبوده از پیشفرضِ رجیستری پر میشود. migration دادهٔ انبوه لازم نیست و نباید نوشته شود.
معیار پذیرش
- ✅ موفق:
GET /api/v1/permission-catalogبا توکن مالک کلینیک → ۲۰۰ و فهرست همهٔ منابع با actionها و برچسب فارسی. صفحهٔ/admin/my-secretariesو مودال مجوز پزشک هر دو از همین پاسخ رندر میشوند و هیچ فهرست هاردکدی ندارند. - ✅ موفق: افزودن یک منبع تازه به رجیستری (مثلاً
treatment) → بدون هیچ تغییر دیگری در فرانت، هم در فرم منشی و هم در مودال پزشک ظاهر میشود. - ✅ موفق: منشیِ بدون
treatment.viewوارد/admin/treatment-casesشود → به داشبورد برگردانده شود (RoleRoute)، وGET /api/v1/treatment-casesبرایش ۴۰۳ بدهد. - ❌ خطا:
PUTمجوز با منبعِ ناشناخته یا actionِ ناشناخته → ۴۲۲، و مقدار قبلی دستنخورده بماند. - ⚠️ مرزی: منشیِ ساختهشده قبل از این تغییر که
treatmentدر JSONش نیست → خواندنش خطا ندهد و آن منبع با پیشفرضِ رجیستری برگردد، نهnull. - ⚠️ مرزی: مالک کلینیک و ادمین همیشه مجازند — رجیستری نباید این را عوض کند
(
ClinicDoctorPermissionChecker::can()خط ۲۶). - ⚠️ مرزی: محیط شخصیِ پزشک
permissionsندارد؛usePermissionsنبودش را «محدودیتی نیست» میخواند و این باید همان بماند.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Secretary/Entity/DoctorSecretary.php |
DEFAULT_PERMISSIONS منشی |
src/Clinic/Entity/ClinicDoctorPermission.php |
DEFAULT_PERMISSIONS پزشک + apply() که actionِ ناشناخته را دور میریزد |
src/Secretary/Security/SecretaryPermissionChecker.php |
بررسی مجوز منشی |
src/Clinic/Security/ClinicDoctorPermissionChecker.php |
بررسی مجوز پزشک |
assets/admin/pages/MySecretariesPage.tsx |
فرم مجوز منشی |
assets/admin/components/ui/DoctorPermissionsModal.tsx |
مودال مجوز پزشک |
assets/admin/hooks/usePermissions.ts |
can(resource, action) در فرانت |
assets/admin/App.tsx |
گیتِ route با permission={[resource, action]} |
docs/api/secretary.md · docs/api/clinic.md |
سند اندپوینتها |
وضعیت فعلی
رجیستری منشی
public const DEFAULT_PERMISSIONS = [
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
'patients' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
// …
'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'subscription' => ['view' => false, 'create' => false],
],
];
رجیستری پزشک — دو منبع کمتر، و services با actionهای متفاوت
public const DEFAULT_PERMISSIONS = [
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true],
'services' => ['view' => true, 'update' => false],
// clinic_doctors و subscription اصلاً نیستند
],
];
فرانت — همان فهرست، بار سوم و چهارم
// DoctorPermissionsModal.tsx
const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, string> }> = {
appointments: {
label: 'نوبتها',
actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' },
},
// …
};
// MySecretariesPage.tsx
const EMPTY_PERMISSIONS: SecretaryPermissions = {
appointments: { view: false, create: false, cancel: false, update_status: false },
// …
};
گیتِ فعلیِ صفحههای تازه
<Route path="resources" element={<RoleRoute roles={['doctor','clinic','secretary']} permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="treatment-cases" element={<RoleRoute roles={['doctor','clinic','secretary']} permission={['appointments', 'view']}><TreatmentCasesPage /></RoleRoute>} />
وظایف
۱. رجیستری منابع — منبع واحد
src/Shared/Security/PermissionCatalog.php
یک کلاس با یک ثابت: هر منبع، actionهایش، و برچسب فارسی. هیچ پیشفرضی اینجا نیست — اینجا فقط «چه چیزهایی وجود دارند».
final class PermissionCatalog
{
/** @var array<string, array{label: string, actions: array<string, string>}> */
public const RESOURCES = [
'appointments' => [
'label' => 'نوبتها',
'actions' => ['view' => 'مشاهده', 'create' => 'ایجاد', 'cancel' => 'لغو', 'update_status' => 'تغییر وضعیت'],
],
// …
'resources' => [
'label' => 'منابع و دستگاهها',
'actions' => ['view' => 'مشاهده', 'create' => 'ایجاد', 'update' => 'ویرایش', 'delete' => 'حذف'],
],
'treatment' => [
'label' => 'درمانهای چندجلسهای',
'actions' => ['view' => 'مشاهده', 'update' => 'ویرایش'],
],
];
/** شکلِ خالی — برای merge با مقدارِ ذخیرهشده. */
public static function blank(): array { /* همهٔ actionها false */ }
/** مقدارِ ذخیرهشده + کلیدهای نبوده از پیشفرضِ نقش. */
public static function merge(array $stored, array $roleDefaults): array { /* … */ }
}
دو منبع تازه که باید اضافه شوند (چون صفحهشان امروز مجوزِ قرضی دارد):
resources برای پنج صفحهٔ منابع، و treatment برای treatment-cases.
الگو: Registry/Catalog. دلیل: چهار فهرستِ موازی که دستی همگام میشوند، دیر یا زود واگرا میشوند — و همین حالا شدهاند. یک ثابت که هر چهار مصرفکننده از آن میخوانند، واگرایی را از نظر ساختاری غیرممکن میکند.
نحوه تست: تست واحد در tests/Shared/ — blank() باید برای هر منبعِ رجیستری کلید
بدهد؛ merge() با یک stored که یک منبع کم دارد باید آن را از پیشفرضِ نقش پر کند و
مقادیرِ موجود را دست نزند.
۲. هر دو Entity از رجیستری بخوانند
DEFAULT_PERMISSIONS هر دو کلاس میماند ولی معنیاش عوض میشود: فقط پیشفرضِ نقش،
نه تعریفِ ساختار. اعتبارسنجیِ apply() باید به PermissionCatalog::RESOURCES نگاه کند
نه به self::DEFAULT_PERMISSIONS.
// ClinicDoctorPermission::apply() — امروز:
if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) { continue; }
// باید بشود:
if (!is_array($actions) || !isset(PermissionCatalog::RESOURCES[$resource])) { continue; }
با این تغییر، services.create برای پزشک هم قابل ذخیره میشود — چون رجیستری واحد
است. همین «شبیه شدنِ منشی و پزشک» است.
نحوه تست: تست موجودِ مجوز پزشک را اجرا کن، بعد یک تست تازه: ذخیرهٔ
services.create = true برای پزشک، خواندن دوباره، و بررسی اینکه مانده — امروز دور
ریخته میشود.
۳. اندپوینت کاتالوگ
GET /api/v1/permission-catalog
Permission: IS_AUTHENTICATED_FULLY. پاسخ ثابت است و به کاربر بستگی ندارد، پس
جای مناسبی برای cache سمت کلاینت است (staleTime بلند).
{ "success": true, "data": { "resources": [
{ "key": "appointments", "label": "نوبتها",
"actions": [ { "key": "view", "label": "مشاهده" }, … ] }
] } }
آرایه است نه object، تا ترتیبِ نمایش تضمین شود؛ ترتیبِ کلیدهای JSON قرارداد نیست.
نحوه تست:
TOKEN=... # 09390039833 / QaTest@1234
curl -sk https://clinic-pro.ddev.site/api/v1/permission-catalog -H "Authorization: Bearer $TOKEN"
باید همهٔ منابع بیایند. بدون توکن → ۴۰۱.
۴. هر دو UI از کاتالوگ رندر شوند
RESOURCE_LABELS و EMPTY_PERMISSIONS و PERMISSION_SECTIONS حذف میشوند و جایشان
یک hook میآید:
// assets/admin/hooks/usePermissionCatalog.ts
export function usePermissionCatalog() {
return useQuery({
queryKey: ['permission-catalog'],
queryFn: () => api.get<ApiResponse<{ resources: CatalogResource[] }>>('/api/v1/permission-catalog'),
staleTime: Infinity,
});
}
clinicOnly که امروز در PERMISSION_SECTIONS است باید به رجیستری منتقل شود، وگرنه
همان منطق در فرانت هاردکد میماند.
سه حالت داده اجباری است: تا وقتی کاتالوگ نیامده، فرم اسکلتون نشان دهد نه فهرست خالی — «هیچ مجوزی وجود ندارد» با «در حال خواندن» یکی نیست.
نحوه تست: vitest برای هر دو کامپوننت با mock کردن کاتالوگ؛ سناریو: کاتالوگ با سه منبع → سه بخش رندر شود. سپس یک منبع به mock اضافه کن و بررسی کن بدون تغییر کد ظاهر شود — این همان «داینامیک بودن» است و باید تست داشته باشد.
۵. گیتِ درستِ صفحهها
App.tsx — پنج صفحهٔ منابع از appointment_settings به resources منتقل شوند و
treatment-cases به treatment.
سمت backend هم باید همانجا بسته شود، وگرنه گیتِ فرانت فقط دکوراسیون است:
TreatmentCaseController و کنترلرهای src/Resource/ باید
secretaryAccess->denyUnlessGranted($user, 'treatment', 'view') بزنند — الگوی موجود در
StaffController::list() را ببین.
این را قبل از کد بررسی کن: آیا صفحهٔ دیگری هم مجوزِ قرضی دارد؟ خروجی این دستور را با فهرست صفحهها مقایسه کن:
grep -oE 'path="[a-z-]+"|permission=\{\[[^]]*\]' assets/admin/App.tsx
نحوه تست: منشیای با treatment.view = false بساز، توکنش را بگیر و
GET /api/v1/treatment-cases بزن → باید ۴۰۳ بدهد. بعد true کن و ۲۰۰ بگیر.
۶. بررسی و تست همهٔ صفحهها
کاربر صریح خواسته «همه صفحات بررسی و تست شود». این یعنی یک عبورِ سیستماتیک، نه نگاه اجمالی:
۱. با کاربر منشی و همهٔ مجوزها خاموش، هر route را باز کن. هیچ صفحهای نباید داده
نشان دهد؛ همه باید به داشبورد برگردند.
۲. یکییکی view هر منبع را روشن کن و بررسی کن فقط صفحههای همان منبع باز شوند.
۳. همان دو مرحله برای پزشکِ دعوتشده.
اسکریپت کمکی برای مرحلهٔ ۱ (درایورِ اسکرینشات آدرسِ نهایی را گزارش میکند):
node .claude/skills/redesign-page/driver.mjs shot "<url>" --out /tmp/x.png --wait 5000
# ⚠ WRONG PAGE یعنی گیت کار کرده
نتیجه را بهصورت جدول گزارش کن: route، منبعِ گیت، رفتار مشاهدهشده. صفحهای که تست نشده را «تست شد» ننویس.
نکات مهم
- رجیستری فقط ساختار میدهد، نه سیاست. پیشفرضِ هر نقش در همان Entity میماند. آوردنشان به رجیستری یعنی یک فایل که هم «چه چیزی هست» و هم «چه کسی چه دارد» را میداند — دو مسئولیت.
- هیچ migration دادهٔ انبوهی ننویس.
merge()در زمان خواندن کار را میکند و ستونjsonهر دو جدول نیازی به تغییر schema ندارد. migration فقط اگر ستونی اضافه شود، که اینجا نمیشود. - مالک کلینیک و ادمین همیشه مجازند — این قاعده در
ClinicDoctorPermissionChecker::can()است و دست نمیخورد. مالک هرگز نباید بتواند خودش را قفل کند. usePermissionsنبودِpermissionsرا «محدودیتی نیست» میخواند (محیط شخصی). این رفتار نباید عوض شود؛ رجیستری فقط جایی اثر دارد که مجوز واقعاً ذخیره شده باشد.- بعد از تغییر اندپوینتها،
docs/api/secretary.mdوdocs/api/clinic.mdدر همان جلسه بهروز شوند، با JSON واقعی از اجرای واقعی. - تستِ «داینامیک بودن» را جدی بگیر: تستی که فقط منابعِ امروز را چک کند، فردا که منبع تازه اضافه شود چیزی به تو نمیگوید. تست باید افزودنِ یک منبع به mock را بررسی کند.