diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 5265060f..e907df2a 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -297,16 +297,16 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> - } /> + } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> {/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینک‌های قدیمی نباید بشکنند. */} } /> {/* تقویم رسمی کشور — فقط مدیر سیستم. /holidays مالِ محیط است و فقط استثنا می‌زند. */} diff --git a/assets/admin/components/ui/DoctorPermissionsModal.test.tsx b/assets/admin/components/ui/DoctorPermissionsModal.test.tsx new file mode 100644 index 00000000..9726fc25 --- /dev/null +++ b/assets/admin/components/ui/DoctorPermissionsModal.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; + +vi.mock('../../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { api } from '../../lib/api'; +import DoctorPermissionsModal from './DoctorPermissionsModal'; + +const get = api.get as ReturnType; + +const resource = (key: string, label: string, actions: [string, string][]) => ({ + key, + label, + clinic_only: false, + actions: actions.map(([k, l]) => ({ key: k, label: l })), +}); + +const BASE_CATALOG = [ + resource('appointments', 'مدیریت نوبت‌ها', [['view', 'مشاهده'], ['create', 'ایجاد']]), + resource('patients', 'پرونده بیماران', [['view', 'مشاهده'], ['delete', 'حذف']]), + resource('services', 'خدمات و تعرفه‌ها', [['view', 'مشاهده']]), +]; + +const permissionPayload = { + uuid: 'p1', + clinic_uuid: 'c1', + doctor_uuid: 'd1', + doctor_name: 'دکتر تست', + active: true, + permissions: { version: 1, resources: { appointments: { view: true, create: false } } }, +}; + +function mockApi(catalog: unknown[]) { + get.mockImplementation((url: string) => { + if (url.includes('permission-catalog')) { + return Promise.resolve({ success: true, data: { version: 1, resources: catalog } }); + } + return Promise.resolve({ success: true, data: permissionPayload }); + }); +} + +function render(catalog: unknown[] = BASE_CATALOG) { + mockApi(catalog); + return renderWithProviders( + {}} />, + ); +} + +describe('DoctorPermissionsModal', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders one row per catalog resource', async () => { + render(); + + expect(await screen.findByText('مدیریت نوبت‌ها')).toBeInTheDocument(); + expect(screen.getByText('پرونده بیماران')).toBeInTheDocument(); + expect(screen.getByText('خدمات و تعرفه‌ها')).toBeInTheDocument(); + }); + + /** + * قلبِ «داینامیک بودن»: منبع تازه فقط به mock اضافه می‌شود و هیچ خطی از کد + * کامپوننت عوض نمی‌شود. اگر این تست بشکند یعنی فهرست دوباره هاردکد شده. + */ + it('shows a resource added to the catalog with no code change', async () => { + render([...BASE_CATALOG, resource('brand_new_page', 'صفحهٔ کاملاً تازه', [['view', 'مشاهده']])]); + + expect(await screen.findByText('صفحهٔ کاملاً تازه')).toBeInTheDocument(); + }); + + it('renders a switch only for actions the resource actually has', async () => { + render(); + + // patients در کاتالوگ view و delete دارد، ولی create ندارد. + expect(await screen.findByLabelText('پرونده بیماران — حذف')).toBeInTheDocument(); + expect(screen.queryByLabelText('پرونده بیماران — ایجاد')).not.toBeInTheDocument(); + }); + + it('reflects stored values and defaults missing ones to off', async () => { + render(); + + await waitFor(() => expect(screen.getByLabelText('مدیریت نوبت‌ها — مشاهده')).toBeChecked()); + expect(screen.getByLabelText('مدیریت نوبت‌ها — ایجاد')).not.toBeChecked(); + // services اصلاً در JSONِ ذخیره‌شده نیست + expect(screen.getByLabelText('خدمات و تعرفه‌ها — مشاهده')).not.toBeChecked(); + }); + + it('shows loading rather than an empty table while the catalog is in flight', () => { + get.mockImplementation(() => new Promise(() => {})); + + renderWithProviders( + {}} />, + ); + + expect(screen.getByText('در حال بارگذاری...')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/ui/DoctorPermissionsModal.tsx b/assets/admin/components/ui/DoctorPermissionsModal.tsx index e3fb829d..e421f489 100644 --- a/assets/admin/components/ui/DoctorPermissionsModal.tsx +++ b/assets/admin/components/ui/DoctorPermissionsModal.tsx @@ -5,6 +5,7 @@ import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import Modal from './Modal'; import Switch from './Switch'; +import { usePermissionCatalog } from '../../hooks/usePermissionCatalog'; /** envelope کامل — همان چیزی که بک‌اند برمی‌گرداند، بدون flatten. */ export interface PermissionEnvelope { @@ -21,61 +22,7 @@ export interface ClinicDoctorPermissionPayload { 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: 'ویرایش' }, - }, - insurances: { - label: 'بیمه‌ها', - actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' }, - }, - addresses: { - label: 'آدرس‌ها', - actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' }, - }, - inventory: { - label: 'انبارداری', - actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' }, - }, - tags: { - label: 'تگ‌ها', - actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' }, - }, - staff: { - label: 'پرسنل', - actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' }, - }, - discounts: { - label: 'تخفیف‌ها', - actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' }, - }, - sms: { - label: 'پیامک‌ها', - actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' }, - }, -}; - +/** ستون‌های ثابتِ جدول؛ هر منبع فقط ستون‌هایی را پر می‌کند که کاتالوگ برایش داده. */ const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status']; const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت']; @@ -86,6 +33,7 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN onClose: () => void; }) { const qc = useQueryClient(); + const catalog = usePermissionCatalog(); const [resources, setResources] = useState({}); const [active, setActive] = useState(true); @@ -135,7 +83,7 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN {open && (
- {section.items.map((item) => ( + {section.actions.map((item) => (
- + {catalog.isLoading ? ( +

در حال بارگذاری فهرست دسترسی‌ها...

+ ) : catalog.isError ? ( +

فهرست دسترسی‌ها خوانده نشد. صفحه را دوباره باز کنید.

+ ) : ( + + )}
); diff --git a/assets/admin/pages/ResourceDetailPage.tsx b/assets/admin/pages/ResourceDetailPage.tsx index 3bebdf6c..a447da5c 100644 --- a/assets/admin/pages/ResourceDetailPage.tsx +++ b/assets/admin/pages/ResourceDetailPage.tsx @@ -51,7 +51,7 @@ export default function ResourceDetailPage() { const { offerings, save: saveServices } = useResourceServices(resourceUuid); const { items: serviceOptions } = useAllServiceItems(); const { can } = usePermissions(); - const canUpdate = can('appointment_settings', 'update'); + const canUpdate = can('resources', 'update'); const [editOpen, setEditOpen] = useState(false); const [confirmDeactivate, setConfirmDeactivate] = useState(false); diff --git a/assets/admin/pages/ResourcePoolsPage.tsx b/assets/admin/pages/ResourcePoolsPage.tsx index 2bcdebb0..ba68b774 100644 --- a/assets/admin/pages/ResourcePoolsPage.tsx +++ b/assets/admin/pages/ResourcePoolsPage.tsx @@ -22,7 +22,7 @@ export default function ResourcePoolsPage() { const { pools, loading, create, update, remove, setMembers } = useResourcePools(); const { types } = useResourceTypes(); const { can } = usePermissions(); - const canUpdate = can('appointment_settings', 'update'); + const canUpdate = can('resources', 'update'); const [urlState, setUrlState] = useUrlState({ search: '' }); const [creating, setCreating] = useState(false); diff --git a/assets/admin/pages/ResourceTypesPage.tsx b/assets/admin/pages/ResourceTypesPage.tsx index 952f1970..e28bb442 100644 --- a/assets/admin/pages/ResourceTypesPage.tsx +++ b/assets/admin/pages/ResourceTypesPage.tsx @@ -19,7 +19,7 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav'; export default function ResourceTypesPage() { const { types, loading, create, update, remove } = useResourceTypes(); const { can } = usePermissions(); - const canUpdate = can('appointment_settings', 'update'); + const canUpdate = can('resources', 'update'); const [urlState, setUrlState] = useUrlState({ search: '' }); const [editing, setEditing] = useState<{ open: boolean; type: ResourceType | null }>({ open: false, type: null }); diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index 089bb403..7960c11a 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -35,7 +35,7 @@ export default function ResourcesPage() { const { types } = useResourceTypes(); const { skills } = useSkills(); const { can } = usePermissions(); - const canUpdate = can('appointment_settings', 'update'); + const canUpdate = can('resources', 'update'); const { resources, loading, create } = useResources({ type_uuid: urlState.type || undefined, diff --git a/assets/admin/pages/SecretariesPage.tsx b/assets/admin/pages/SecretariesPage.tsx index 2d85ce08..730bcb48 100644 --- a/assets/admin/pages/SecretariesPage.tsx +++ b/assets/admin/pages/SecretariesPage.tsx @@ -14,176 +14,30 @@ import Pagination from '../components/ui/Pagination'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import Modal from '../components/ui/Modal'; import Switch from '../components/ui/Switch'; +import { usePermissionCatalog, alignPermissions } from '../hooks/usePermissionCatalog'; +import type { CatalogResource } from '../hooks/usePermissionCatalog'; -const DEFAULT_PERMISSIONS: SecretaryPermissions = { - appointments: { view: true, create: false, cancel: false, update_status: false }, - patients: { view: true, create: false, update: false, delete: false }, - payments: { view: true, create: false, update: false, delete: false }, - insurances: { view: true, create: false, update: false, delete: false }, - addresses: { view: true, create: false, update: false, delete: false }, - clinic_info: { view: true, update: false }, - inventory: { view: false, create: false, update: false, delete: false }, - tags: { view: false, create: false, update: false, delete: false }, - services: { view: false, create: false, update: false, delete: false }, - staff: { view: false, create: false, update: false, delete: false }, - discounts: { view: false, create: false, update: false, delete: false }, - sms: { view: false, create: false, update: false, delete: false }, - appointment_settings: { view: false, update: false }, - clinic_doctors: { view: false, create: false, update: false, delete: false }, - subscription: { view: false, create: false }, -}; - -type PermSection = keyof SecretaryPermissions; - -const PERMISSION_LABELS: Record = { - appointments: { - label: 'نوبت‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'cancel', label: 'لغو' }, - { key: 'update_status', label: 'تغییر وضعیت' }, - ], - }, - patients: { - label: 'پرونده بیماران', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - payments: { - label: 'پرداخت‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - addresses: { - label: 'آدرس‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - clinic_info: { - label: 'اطلاعات کلینیک', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'update', label: 'ویرایش' }, - ], - }, - insurances: { - label: 'بیمه‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - inventory: { - label: 'انبار', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - tags: { - label: 'تگ‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - services: { - label: 'خدمات و تعرفه‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - staff: { - label: 'پرسنل', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - discounts: { - label: 'تخفیف‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - sms: { - label: 'پیامک‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'شارژ/ارسال' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - appointment_settings: { - label: 'تنظیمات نوبت‌دهی', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'update', label: 'ویرایش' }, - ], - }, - clinic_doctors: { - label: 'مدیریت پزشکان کلینیک', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - subscription: { - label: 'خرید اشتراک', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'خرید' }, - ], - }, -}; +const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status']; +const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت']; function PermissionsMatrix({ permissions, onChange, + resources, }: { permissions: SecretaryPermissions; onChange: (p: SecretaryPermissions) => void; + resources: CatalogResource[]; }) { - const toggle = (section: PermSection, action: string) => { - const current = (permissions[section] as Record)[action]; + const toggle = (section: string, action: string) => { onChange({ ...permissions, - [section]: { ...(permissions[section] as Record), [action]: !current }, + [section]: { ...permissions[section], [action]: !permissions[section]?.[action] }, }); }; - const allActions = ['view', 'create', 'update', 'delete', 'cancel', 'update_status']; - const actionHeaders = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت']; + const allActions = ACTION_COLUMNS; + const actionHeaders = ACTION_HEADERS; return (
@@ -197,24 +51,22 @@ function PermissionsMatrix({ - {(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section) => { - const config = PERMISSION_LABELS[section]; - const sectionPerms = permissions[section] as Record; + {resources.map((resource) => { + const available = new Set(resource.actions.map((a) => a.key)); return ( - - {config.label} + + {resource.label} {allActions.map((action) => { - const actionConfig = config.actions.find((a) => a.key === action); - if (!actionConfig) { + if (!available.has(action)) { return —; } return (
toggle(section, action)} - ariaLabel={`${section} — ${action}`} + checked={permissions[resource.key]?.[action] ?? false} + onChange={() => toggle(resource.key, action)} + ariaLabel={`${resource.label} — ${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`} />
@@ -238,7 +90,8 @@ export default function SecretariesPage() { const setPage = (p: number) => setUrlState({ page: String(p) }); const setSearch = (v: string) => setUrlState({ search: v, page: '1' }); const [editTarget, setEditTarget] = useState(null); - const [editPerms, setEditPerms] = useState(DEFAULT_PERMISSIONS); + const [editPerms, setEditPerms] = useState({}); + const catalog = usePermissionCatalog(); const [deleteTarget, setDeleteTarget] = useState(null); const limit = 15; @@ -274,7 +127,8 @@ export default function SecretariesPage() { const openEdit = (s: Secretary) => { setEditTarget(s); - setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS); + // شکل را کاتالوگ می‌دهد؛ منبعی که در JSONِ ذخیره‌شده نیست خاموش نمایش داده می‌شود. + setEditPerms(alignPermissions(s.permissions, catalog.resources)); }; const columns: Column[] = [ @@ -347,14 +201,20 @@ export default function SecretariesPage() { } > - + {catalog.isLoading ? ( +

در حال بارگذاری فهرست دسترسی‌ها...

+ ) : catalog.isError ? ( +

فهرست دسترسی‌ها خوانده نشد. صفحه را دوباره باز کنید.

+ ) : ( + + )} ({ open: false, skill: null }); diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 74cc2a83..d55d857a 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -512,92 +512,14 @@ export interface Secretary { }; } -export interface SecretaryPermissions { - appointments: { - view: boolean; - create: boolean; - cancel: boolean; - update_status: boolean; - }; - patients: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - payments: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - insurances: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - addresses: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - clinic_info: { - view: boolean; - update: boolean; - }; - inventory: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - tags: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - services: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - staff: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - discounts: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - sms: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - appointment_settings: { - view: boolean; - update: boolean; - }; - clinic_doctors: { - view: boolean; - create: boolean; - update: boolean; - delete: boolean; - }; - subscription: { - view: boolean; - create: boolean; - }; -} +/** + * مجوزهای یک رابطه — نقشهٔ باز، نه interface با فیلدهای ثابت. + * + * فهرستِ منابع از GET /api/v1/permission-catalog می‌آید و ممکن است با افزودن + * صفحهٔ تازه رشد کند؛ تایپِ فیلد-به-فیلد یعنی هر منبع جدید یک خطای کامپایل. + * منبع/اکشنِ ناشناخته با ?? false خوانده می‌شود. + */ +export type SecretaryPermissions = Record>; export interface Specialty { id?: number; diff --git a/src/Resource/Controller/HolidayController.php b/src/Resource/Controller/HolidayController.php index 6898cd9d..8d72a464 100644 --- a/src/Resource/Controller/HolidayController.php +++ b/src/Resource/Controller/HolidayController.php @@ -25,6 +25,12 @@ class HolidayController extends BaseController { use ResourcePermissionTrait; + /** تعطیلات زیرمجموعهٔ تنظیمات نوبت‌دهی است، نه فهرست منابع. */ + private function permissionResource(): string + { + return 'appointment_settings'; + } + public function __construct( private readonly HolidayService $holidays, private readonly ResourceContext $context, diff --git a/src/Resource/Controller/ResourcePermissionTrait.php b/src/Resource/Controller/ResourcePermissionTrait.php index 1075ac11..e981380b 100644 --- a/src/Resource/Controller/ResourcePermissionTrait.php +++ b/src/Resource/Controller/ResourcePermissionTrait.php @@ -10,11 +10,14 @@ use App\Shared\Exception\AppException; use Symfony\Contracts\Service\Attribute\Required; /** - * گِیتِ مشترک چهار کنترلر این دامنه. + * گِیتِ مشترک کنترلرهای این دامنه. * - * مجوز `appointment_settings` بازاستفاده می‌شود و مجوز تازه‌ای ساخته نمی‌شود: منابع - * بخشی از پیکربندی نوبت‌دهی‌اند و افزودن یک کلید تازه یعنی یک ستون تازه در جدول - * مجوزهای هر منشی و هر پزشکِ عضو، بدون اینکه کسی خواسته باشد آن‌ها را جدا کند. + * منابع مجوزِ خودشان را دارند (`resources`). پیش از رجیستریِ واحد، این کنترلرها + * `appointment_settings` را قرض می‌گرفتند چون افزودن یک کلید تازه یعنی ویرایش + * دستیِ شش فهرست؛ حالا یک ردیف در `PermissionCatalog` کافی است. + * + * تعطیلات از این قاعده مستثناست و `appointment_settings` می‌ماند — صفحه‌اش + * زیرمجموعهٔ تنظیمات نوبت‌دهی است، نه فهرست دستگاه‌ها. */ trait ResourcePermissionTrait { @@ -30,11 +33,19 @@ trait ResourcePermissionTrait $this->clinicDoctorAccess = $clinicDoctorAccess; } - /** @param 'view'|'update' $action */ + /** کنترلری که منبعِ دیگری را گِیت می‌کند این را بازنویسی می‌کند. */ + private function permissionResource(): string + { + return 'resources'; + } + + /** @param 'view'|'create'|'update'|'delete' $action */ private function denyUnlessGranted(User $user, string $action): void { - $this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action); - $this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action); + $resource = $this->permissionResource(); + + $this->secretaryAccess->denyUnlessGranted($user, $resource, $action); + $this->clinicDoctorAccess->denyUnlessGranted($user, $resource, $action); } /** @@ -48,9 +59,11 @@ trait ResourcePermissionTrait */ private function denyUnlessGrantedForBooking(User $user): void { + $resource = $this->permissionResource(); + $allowed = - ($this->secretaryAccess->canOrNonSecretary($user, 'appointment_settings', 'view') - && $this->clinicDoctorAccess->canOrNonMember($user, 'appointment_settings', 'view')) + ($this->secretaryAccess->canOrNonSecretary($user, $resource, 'view') + && $this->clinicDoctorAccess->canOrNonMember($user, $resource, 'view')) || ($this->secretaryAccess->canOrNonSecretary($user, 'appointments', 'view') && $this->clinicDoctorAccess->canOrNonMember($user, 'appointments', 'view')); diff --git a/src/Treatment/Controller/TreatmentCaseController.php b/src/Treatment/Controller/TreatmentCaseController.php index 78352b51..4b89f5c8 100644 --- a/src/Treatment/Controller/TreatmentCaseController.php +++ b/src/Treatment/Controller/TreatmentCaseController.php @@ -23,6 +23,8 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; +use App\Clinic\Security\ClinicDoctorAccessChecker; +use App\Secretary\Security\SecretaryAccessChecker; use Symfony\Component\Security\Http\Attribute\IsGranted; #[OA\Tag(name: 'Treatment')] @@ -40,11 +42,28 @@ class TreatmentCaseController extends BaseController private readonly TreatmentCaseEditor $editor, private readonly TreatmentPlanProjector $planner, private readonly \App\UserProfile\Repository\UserProfileRepository $profiles, + private readonly SecretaryAccessChecker $secretaryAccess, + private readonly ClinicDoctorAccessChecker $clinicDoctorAccess, ) {} + /** + * تا پیش از رجیستریِ واحد، این کنترلر هیچ گِیت مجوزی نداشت و صفحه‌اش در پنل + * روی `appointments.view` سوار بود — یعنی هر منشی‌ای که اجازهٔ دیدن نوبت داشت + * پروندهٔ درمان را هم می‌دید. + * + * @param 'view'|'update' $action + */ + private function denyUnlessGranted(User $user, string $action): void + { + $this->secretaryAccess->denyUnlessGranted($user, 'treatment', $action); + $this->clinicDoctorAccess->denyUnlessGranted($user, 'treatment', $action); + } + #[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])] public function list(#[CurrentUser] User $user, Request $request): JsonResponse { + $this->denyUnlessGranted($user, 'view'); + [$entityType, $entityId] = $this->branches->pair($user); $status = $request->query->get('status'); @@ -88,6 +107,8 @@ class TreatmentCaseController extends BaseController #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])] public function show(#[CurrentUser] User $user, string $uuid): JsonResponse { + $this->denyUnlessGranted($user, 'view'); + $case = $this->requireCase($user, $uuid); /** @@ -111,6 +132,8 @@ class TreatmentCaseController extends BaseController #[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])] public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { + $this->denyUnlessGranted($user, 'update'); + $data = json_decode($request->getContent(), true); if (!is_array($data)) { @@ -131,6 +154,8 @@ class TreatmentCaseController extends BaseController #[Route('/api/v1/treatment-sessions/unbooked', name: 'treatment_sessions_unbooked', methods: ['GET'])] public function unbooked(#[CurrentUser] User $user, Request $request): JsonResponse { + $this->denyUnlessGranted($user, 'view'); + [$entityType, $entityId] = $this->branches->pair($user); $withinDays = (int) $request->query->get('within_days', 7); @@ -157,6 +182,8 @@ class TreatmentCaseController extends BaseController #[Route('/api/v1/treatment-case/{uuid}/plan', name: 'treatment_case_plan', methods: ['GET'])] public function plan(#[CurrentUser] User $user, string $uuid): JsonResponse { + $this->denyUnlessGranted($user, 'view'); + $case = $this->requireCase($user, $uuid); /** @@ -203,6 +230,8 @@ class TreatmentCaseController extends BaseController #[Route('/api/v1/treatment-session/{uuid}', name: 'treatment_session_show', methods: ['GET'])] public function showSession(#[CurrentUser] User $user, string $uuid): JsonResponse { + $this->denyUnlessGranted($user, 'view'); + $session = $this->requireSession($user, $uuid); $case = $session->getTreatmentCase(); @@ -225,6 +254,8 @@ class TreatmentCaseController extends BaseController #[Route('/api/v1/treatment-session/{uuid}/slot-suggestions', name: 'treatment_session_slots', methods: ['GET'])] public function slotSuggestions(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { + $this->denyUnlessGranted($user, 'view'); + $session = $this->requireSession($user, $uuid); $resourceUuid = $request->query->get('resource_uuid');