From 54c8b008bf48ec185aded6dc9247a8192b23e4db Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 23 Jul 2026 18:01:32 +0330 Subject: [PATCH] fix(secretary): settings menu structure, clinic timeline access, patient delete gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reported secretary-access bugs. 1) Settings menu structure. Phase B flat-listed staff/discounts/sms/tags/ appointment_settings/clinic_doctors in the secretary's main sidebar. Mirror the doctor/clinic layout instead: only inventory + services stay in the main «مدیریت» nav; the rest live under a single «تنظیمات» entry (→ /admin/account-settings). Made both settings navs permission-aware for secretaries: SETTINGS_MENU (menuForRole now takes `can`) and PurchaseSubscriptionSidebar filter by a per-item `perm`/`alwaysOpen` instead of role only, so a secretary sees exactly their permitted settings pages and owner-only items (subscription, secretary-management) stay hidden. 2) Clinic secretary appointment timeline. AppointmentsPage treated a clinic-scoped secretary as a single-doctor profile: the doctor list was fetched/shown only for isClinic/isAdmin, so no doctor tabs, timeline, or booking. Now a clinic-scoped secretary is multi-doctor: fetches the doctor list, shows tabs, auto-selects the first doctor. The list comes from a new authenticated endpoint GET /api/v1/my/clinic-doctors returning only the secretary's ASSIGNED doctors — /clinic/doctor-list is on the public (no-JWT) firewall and cannot scope by user, so it would have leaked unbookable doctors. 3) Patient record delete. The `patients.delete` toggle was dead: every record delete (note/medical-record/attachment/call/message) was gated as `patients.update`. Mapped them to `patients.delete` so the toggle is honored and delete is controllable separately from edit. New SecretaryAccessChecker::assignedClinicDoctorIds. Tests: doctor-list scoping, patients.delete separation (denied/allowed). docs/api secretary.md + appointment.md updated. Backend 286 + frontend 25 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PurchaseSubscriptionSidebar.test.tsx | 18 ++++++ .../layout/PurchaseSubscriptionSidebar.tsx | 50 ++++++++++----- .../components/layout/SettingsLayout.tsx | 42 +++++++++---- .../admin/components/layout/Sidebar.test.tsx | 52 ++++++--------- assets/admin/components/layout/Sidebar.tsx | 63 +++++-------------- assets/admin/pages/AppointmentsPage.tsx | 22 +++++-- assets/admin/pages/SettingsMenuPage.tsx | 4 +- docs/api/appointment.md | 23 +++++++ docs/api/secretary.md | 2 +- .../Controller/MyAppointmentsController.php | 37 +++++++++++ src/Clinic/Controller/ClinicController.php | 3 + src/Patient/Controller/PatientController.php | 10 +-- .../Security/SecretaryAccessChecker.php | 19 ++++++ .../SecretaryResourceEnforcementTest.php | 51 +++++++++++++++ 14 files changed, 274 insertions(+), 122 deletions(-) diff --git a/assets/admin/components/layout/PurchaseSubscriptionSidebar.test.tsx b/assets/admin/components/layout/PurchaseSubscriptionSidebar.test.tsx index 97800606..46e47a38 100644 --- a/assets/admin/components/layout/PurchaseSubscriptionSidebar.test.tsx +++ b/assets/admin/components/layout/PurchaseSubscriptionSidebar.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; import { renderWithProviders } from '../../test/utils'; +import { useAuthStore } from '../../stores/authStore'; import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; describe('PurchaseSubscriptionSidebar — settings menu', () => { @@ -20,6 +21,23 @@ describe('PurchaseSubscriptionSidebar — settings menu', () => { expect(screen.getByRole('link', { name: 'مدیریت منشی' })).not.toHaveAttribute('aria-current'); }); + it('برای منشی فقط آیتم‌های مجاز را نشان می‌دهد (staff مجاز، بقیه پنهان)', () => { + useAuthStore.setState({ + primaryRole: 'secretary', + context: { scope: 'clinic', permissions: { resources: { staff: { view: true } } } }, + } as any); + renderWithProviders(, { route: '/admin/staff' }); + + // مجاز + expect(screen.getByRole('link', { name: 'پرسنل' })).toHaveAttribute('href', '/admin/staff'); + expect(screen.getByRole('link', { name: 'حساب کاربری' })).toBeInTheDocument(); + // owner-only / بدون مجوز → پنهان + expect(screen.queryByRole('link', { name: 'مدیریت منشی' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'خرید اشتراک' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'مدیریت تخفیف‌ها' })).not.toBeInTheDocument(); + useAuthStore.setState({ primaryRole: null, context: null } as any); + }); + // regression: the settings menu must stay visible on mobile (was `hidden lg:block`, // which dropped the whole menu below the lg breakpoint → no settings nav on phones). it('is not hidden on mobile', () => { diff --git a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx index e59da11a..21dbf7a6 100644 --- a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx +++ b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { SearchHeaderP } from '../../pages/subscriptionIcons'; import { useAuthStore } from '../../stores/authStore'; +import { usePermissions } from '../../hooks/usePermissions'; /** * Settings sub-navigation for the subscription page — item list and order copied @@ -13,33 +14,52 @@ import { useAuthStore } from '../../stores/authStore'; * This list is intentionally separate from SETTINGS_MENU (SettingsLayout) so the * other settings pages are not affected. */ -/** `roles`: when set, the item is only shown to those roles (omit = every role). */ -type NavItem = { key: string; label: string; to?: string; roles?: string[] }; +/** + * `roles`: when set, the item is only shown to those roles (omit = every role). + * `perm`: [resource, action] — منشی فقط با داشتن این مجوز آیتم را می‌بیند. + * `alwaysOpen`: برای منشی همیشه نمایش داده می‌شود (حساب/تنظیمات پایه). + * آیتم‌های owner-only (خرید اشتراک، مدیریت منشی) نه `perm` دارند نه `alwaysOpen` + * → برای منشی پنهان می‌شوند. + */ +type NavItem = { + key: string; + label: string; + to?: string; + roles?: string[]; + perm?: [string, string]; + alwaysOpen?: boolean; +}; const NAV_ITEMS: NavItem[] = [ { key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription' }, - { key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial' }, - { key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] }, - { key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/settings/appointment-settings', roles: ['clinic'] }, - { key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing' }, - { key: 'discounts', label: 'مدیریت تخفیف‌ها', to: '/admin/discounts', roles: ['doctor', 'clinic'] }, - { key: 'tags', label: 'تگ ها', to: '/admin/tags-settings' }, - { key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet' }, - { key: 'clinic-doctors', label: 'پزشکان کلینیک', to: '/admin/settings/clinic-doctors', roles: ['clinic'] }, + { key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial', perm: ['payments', 'view'] }, + { key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] }, + { key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing', perm: ['insurances', 'view'] }, + { key: 'discounts', label: 'مدیریت تخفیف‌ها', to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] }, + { key: 'tags', label: 'تگ ها', to: '/admin/tags-settings', perm: ['tags', 'view'] }, + { key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet', perm: ['sms', 'view'] }, + { key: 'clinic-doctors', label: 'پزشکان کلینیک', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', to: '/admin/my-secretaries' }, - { key: 'staff', label: 'پرسنل', to: '/admin/staff' }, - { key: 'account', label: 'حساب کاربری', to: '/admin/account-settings' }, - { key: 'security', label: 'تنظیمات' }, + { key: 'staff', label: 'پرسنل', to: '/admin/staff', perm: ['staff', 'view'] }, + { key: 'account', label: 'حساب کاربری', to: '/admin/account-settings', alwaysOpen: true }, + { key: 'security', label: 'تنظیمات', alwaysOpen: true }, ]; export default function PurchaseSubscriptionSidebar({ active }: { active: string }) { const [query, setQuery] = useState(''); const primaryRole = useAuthStore((s) => s.primaryRole); + const { can } = usePermissions(); const items = useMemo( () => NAV_ITEMS - .filter((i) => !i.roles || (primaryRole != null && i.roles.includes(primaryRole))) + // منشی: بر اساس مجوز، نه نقش. آیتمِ بدونِ perm/alwaysOpen برای منشی پنهان است. + .filter((i) => + primaryRole === 'secretary' + ? (i.alwaysOpen || (i.perm ? can(i.perm[0], i.perm[1]) : false)) + : (!i.roles || (primaryRole != null && i.roles.includes(primaryRole))), + ) .filter((i) => i.label.includes(query.trim())), - [query, primaryRole], + [query, primaryRole, can], ); return ( diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 6dd76f1a..fd5725bd 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -17,27 +17,43 @@ export type SettingsMenuItem = { to?: string; /** when set, the item is only shown to these roles (omit = every role) */ roles?: string[]; + /** [resource, action] — منشی فقط با داشتن این مجوز آیتم را می‌بیند. */ + perm?: [string, string]; + /** برای منشی همیشه نمایش داده می‌شود (حساب کاربری). */ + alwaysOpen?: boolean; }; export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' }, { key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] }, - { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] }, - { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'] }, - { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] }, - { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial' }, + { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] }, + { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, + { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, - { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff' }, - { key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing' }, - { key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'] }, - { key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings' }, - { key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet' }, - { key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings' }, + { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, + { key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing', perm: ['insurances', 'view'] }, + { key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] }, + { key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] }, + { key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] }, + { key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true }, ]; -/** Menu items visible to the given role (items without `roles` are shown to all). */ -export function menuForRole(role: string | null | undefined): SettingsMenuItem[] { - return SETTINGS_MENU.filter((i) => !i.roles || (role != null && i.roles.includes(role))); +/** + * Menu items visible to the given role. برای منشی بر اساس مجوز فیلتر می‌شود + * (آیتمِ بدونِ perm/alwaysOpen پنهان است)؛ سایر نقش‌ها با roles. + */ +export function menuForRole( + role: string | null | undefined, + can?: (resource: string, action: string) => boolean, +): SettingsMenuItem[] { + return SETTINGS_MENU.filter((i) => { + if (role === 'secretary') { + if (i.alwaysOpen) return true; + return i.perm && can ? can(i.perm[0], i.perm[1]) : false; + } + return !i.roles || (role != null && i.roles.includes(role)); + }); } /** diff --git a/assets/admin/components/layout/Sidebar.test.tsx b/assets/admin/components/layout/Sidebar.test.tsx index 78fb6d58..ea5fb417 100644 --- a/assets/admin/components/layout/Sidebar.test.tsx +++ b/assets/admin/components/layout/Sidebar.test.tsx @@ -104,55 +104,41 @@ describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () => ); }); - it("با مجوز inventory.view و tags.view، همان آیتم‌ها نمایش داده می‌شوند", () => { - setSecretary({ inventory: { view: true }, tags: { view: true } }); + it("انبارداری با مجوز inventory.view در سایدبار اصلی می‌آید", () => { + setSecretary({ inventory: { view: true } }); renderWithProviders(, { route: "/admin/dashboard" }); expect(screen.getByText("انبارداری").closest("a")).toHaveAttribute( "href", "/admin/inventory", ); - expect(screen.getByText("تگ‌ها").closest("a")).toHaveAttribute( + }); + + it("آیتم «تنظیمات» همیشه برای منشی نمایش داده می‌شود", () => { + setSecretary({ appointments: { view: true } }); + renderWithProviders(, { route: "/admin/dashboard" }); + // «تنظیمات» هم عنوان بخش است هم آیتم؛ فقط لینکِ آیتم را می‌سنجیم. + expect(screen.getByRole("link", { name: "تنظیمات" })).toHaveAttribute( "href", - "/admin/tags-settings", + "/admin/account-settings", ); }); - it("منابع فاز B (staff/discounts/sms/appointment_settings) با مجوز نمایش داده می‌شوند", () => { + it("منابعِ زیرمجموعهٔ تنظیمات (staff/discounts/sms/tags/appointment/clinic_doctors) در سایدبار اصلی نمی‌آیند", () => { + // حتی با همهٔ مجوزها، این‌ها باید فقط داخل صفحهٔ تنظیمات باشند نه nav اصلی. setSecretary({ staff: { view: true }, discounts: { view: true }, sms: { view: true }, + tags: { view: true }, appointment_settings: { view: true }, + clinic_doctors: { view: true }, }); renderWithProviders(, { route: "/admin/dashboard" }); - expect(screen.getByText("پرسنل").closest("a")).toHaveAttribute("href", "/admin/staff"); - expect(screen.getByText("تخفیف‌ها").closest("a")).toHaveAttribute("href", "/admin/discounts"); - expect(screen.getByText("پیامک‌ها").closest("a")).toHaveAttribute("href", "/admin/sms-wallet"); - // scope=clinic → مسیر تنظیمات کلینیک - expect(screen.getByText("تنظیمات نوبت‌دهی").closest("a")).toHaveAttribute( - "href", - "/admin/settings/appointment-settings", - ); - }); - - it("مدیریت پزشکان کلینیک در scope=doctor حتی با مجوز دیده نمی‌شود", () => { - useAuthStore.setState({ - primaryRole: "secretary", - dbUuid: "d1", - userName: "منشی", - availableContexts: [], - context: { scope: "doctor", permissions: { resources: { clinic_doctors: { view: true } } } }, - } as any); - renderWithProviders(, { route: "/admin/dashboard" }); + expect(screen.queryByText("پرسنل")).not.toBeInTheDocument(); + expect(screen.queryByText("تخفیف‌ها")).not.toBeInTheDocument(); + expect(screen.queryByText("پیامک‌ها")).not.toBeInTheDocument(); + expect(screen.queryByText("تگ‌ها")).not.toBeInTheDocument(); + expect(screen.queryByText("تنظیمات نوبت‌دهی")).not.toBeInTheDocument(); expect(screen.queryByText("پزشکان کلینیک")).not.toBeInTheDocument(); }); - - it("مدیریت پزشکان کلینیک در scope=clinic با مجوز دیده می‌شود", () => { - setSecretary({ clinic_doctors: { view: true } }); - renderWithProviders(, { route: "/admin/dashboard" }); - expect(screen.getByText("پزشکان کلینیک").closest("a")).toHaveAttribute( - "href", - "/admin/settings/clinic-doctors", - ); - }); }); diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 8c16a0e0..916d1916 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -18,7 +18,6 @@ import { KeyIcon, LockClosedIcon, PlusIcon, - ReceiptPercentIcon, ShieldCheckIcon, StarIcon, TagIcon, @@ -416,60 +415,28 @@ function buildSections( label: "انبارداری", }); } - if (can("tags", "view")) { - items.push({ - to: "/admin/tags-settings", - icon: TagIcon, - label: "تگ‌ها", - }); - } - if (can("staff", "view")) { - items.push({ - to: "/admin/staff", - icon: UsersIcon, - label: "پرسنل", - }); - } - if (can("discounts", "view")) { - items.push({ - to: "/admin/discounts", - icon: ReceiptPercentIcon, - label: "تخفیف‌ها", - }); - } - if (can("sms", "view")) { - items.push({ - to: "/admin/sms-wallet", - icon: DevicePhoneMobileIcon, - label: "پیامک‌ها", - }); - } - if (can("appointment_settings", "view")) { - items.push({ - // مسیر بسته به محیط فعال: کلینیک vs مطب شخصی. - to: - scope === "clinic" - ? "/admin/settings/appointment-settings" - : "/admin/appointment-settings", - icon: Cog6ToothIcon, - label: "تنظیمات نوبت‌دهی", - }); - } - // مدیریت پزشکان کلینیک فقط در محیطِ کلینیک معنا دارد. - if (scope === "clinic" && can("clinic_doctors", "view")) { - items.push({ - to: "/admin/settings/clinic-doctors", - icon: HeartIcon, - label: "پزشکان کلینیک", - }); - } + // منابعِ زیرمجموعهٔ «تنظیمات» (staff/discounts/sms/tags/appointment_settings/ + // clinic_doctors) در سایدبار اصلی نمی‌آیند — دقیقاً مثل پزشک/کلینیک، فقط داخل + // صفحهٔ «تنظیمات» با منویِ permission-aware نمایش داده می‌شوند. return [ { label: "عمومی", items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }], }, { label: "مدیریت", items }, + { + label: "تنظیمات", + items: [ + { + // لندینگِ تنظیمات؛ حساب کاربری همیشه باز است و منویِ کناری + // بقیهٔ بخش‌های مجاز را بر اساس permission نشان می‌دهد. + to: "/admin/account-settings", + icon: Cog6ToothIcon, + label: "تنظیمات", + }, + ], + }, ]; } diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index c69616db..6a67d574 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -489,10 +489,14 @@ export default function AppointmentsPage() { // در محیط کلینیک، dbUuid شناسهٔ کلینیک است نه پزشک؛ uuid پزشک فقط از doctorUuid می‌آید. const doctorUuid = useAuthStore(s => s.doctorUuid); const clinicUuid = useClinicContext(); + const scope = useAuthStore(s => s.context?.scope); const isAdmin = primaryRole === 'admin'; const isClinic = primaryRole === 'clinic'; const isDoctor = primaryRole === 'doctor'; const isRepresentation = primaryRole === 'representation'; + // منشیِ محیطِ کلینیک باید مثل کلینیک چندپزشکه رفتار کند: تب پزشکان + تایم‌لاین. + // dbUuid در این محیط uuid کلینیک است (نه پزشک) — همان مبنای clinic/doctor-list. + const isClinicScopedSecretary = primaryRole === 'secretary' && scope === 'clinic'; const [params] = useSearchParams(); const today = new Date().toISOString().slice(0, 10); @@ -542,9 +546,15 @@ export default function AppointmentsPage() { // ── Clinic doctors (authoritative list for tabs) const clinicDoctorsQuery = useQuery>({ - queryKey: ['clinic-doctors', dbUuid], - queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), - enabled: isClinic && !!dbUuid, + queryKey: ['clinic-doctors', dbUuid, isClinicScopedSecretary], + // منشی از اندپوینتِ احرازشده می‌گیرد تا فقط پزشکانِ تخصیص‌یافته‌اش بیایند؛ + // کلینیک/ادمین از لیستِ کاملِ عمومیِ کلینیک. + queryFn: () => api.get( + isClinicScopedSecretary + ? '/api/v1/my/clinic-doctors' + : `/api/v1/clinic/doctor-list/${dbUuid}`, + ), + enabled: (isClinic || isClinicScopedSecretary) && !!dbUuid, }); const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? []; @@ -570,16 +580,16 @@ export default function AppointmentsPage() { // نوع پروفایل: کلینیک چندپزشکه (تب دکترها + مدیریت چند پزشک) در برابر پزشک مستقل. // نقش clinic/admin = چندپزشکه؛ نقش doctor (حتی مهمانِ کلینیک) = مستقل، فقط برنامهٔ خودش. - const isMultiDoctorClinic = isClinic || isAdmin; + const isMultiDoctorClinic = isClinic || isAdmin || isClinicScopedSecretary; const showDoctorTabs = isMultiDoctorClinic && doctors.length >= 1; const showDoctorCol = isAdmin && !selectedDoctorUuid; // در کلینیک، اولین دکتر به‌صورت پیش‌فرض انتخاب می‌شود تا زمانبندی مثل طرح پر باشد. useEffect(() => { - if (isClinic && !selectedDoctorUuid && doctors.length > 0) { + if ((isClinic || isClinicScopedSecretary) && !selectedDoctorUuid && doctors.length > 0) { setSelectedDoctorUuid(doctors[0].uuid); } - }, [isClinic, selectedDoctorUuid, doctors]); + }, [isClinic, isClinicScopedSecretary, selectedDoctorUuid, doctors]); // ── محل نوبت‌دهی برای ادمین // ادمین context کلینیکی ندارد (useClinicContext → null)؛ بدون clinic_uuid فقط برنامهٔ diff --git a/assets/admin/pages/SettingsMenuPage.tsx b/assets/admin/pages/SettingsMenuPage.tsx index 94de88f3..e543eb38 100644 --- a/assets/admin/pages/SettingsMenuPage.tsx +++ b/assets/admin/pages/SettingsMenuPage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { ChevronLeftIcon } from '@heroicons/react/24/outline'; import { menuForRole } from '../components/layout/SettingsLayout'; import { useAuthStore } from '../stores/authStore'; +import { usePermissions } from '../hooks/usePermissions'; /** * SettingsMenuPage — the settings landing list for doctor/clinic users. @@ -12,7 +13,8 @@ import { useAuthStore } from '../stores/authStore'; */ export default function SettingsMenuPage() { const primaryRole = useAuthStore((s) => s.primaryRole); - const items = menuForRole(primaryRole); + const { can } = usePermissions(); + const items = menuForRole(primaryRole, can); return (
diff --git a/docs/api/appointment.md b/docs/api/appointment.md index bf6cf2f6..d005283b 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -1045,3 +1045,26 @@ sessions exist, otherwise one of: Clients must not translate an empty `sessions` array into "closed". The admin panel used to do exactly that and reported «این روز تعطیل است» for a doctor whose clinic schedule was perfectly active — the request simply carried no `clinic_uuid`. + +--- + +## GET /api/v1/my/clinic-doctors + +**Permission:** `IS_AUTHENTICATED_FULLY` + +پزشکانِ در دسترسِ کاربرِ پنل، برای ساختِ تب‌ها/تایم‌لاینِ صفحهٔ نوبت‌ها. برخلاف +`GET /api/v1/clinic/doctor-list/{clinicUuid}` که روی firewallِ عمومی است و **همهٔ** پزشکانِ +کلینیک را برمی‌گرداند، این اندپوینت احرازشده است و نتیجه را بر اساس نقش محدود می‌کند: + +- **منشیِ محیطِ کلینیک** → فقط پزشکانِ **تخصیص‌یافته** به همان منشی (`DoctorSecretary` فعال). +- **منشیِ محیطِ مطب** → همان یک پزشک. +- **کلینیک** → همهٔ پزشکانِ کلینیک · **پزشک** → خودش. + +بدونِ این، منشیِ کلینیک پزشکی را در تب می‌دید که برایش مجوزِ نوبت نداشت و روی +slot/booking، `403` می‌گرفت. + +**Response `200`:** + +```json +{ "success": true, "data": { "data": [ { "uuid": "…", "name": "دکتر …" } ] } } +``` diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 41be72d1..052be039 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -137,7 +137,7 @@ Create a secretary for a doctor. | Resource | Enforced in | Action → endpoint | | --- | --- | --- | | `appointments` | `AppointmentAccessChecker`, `MyAppointmentsController`, `DashboardController` | view/create/cancel/update_status | -| `patients` | `PatientController` (خواندن‌ها via `scope()` → بدون `view` هیچ پرونده‌ای؛ نوشتن‌ها با guard) | view/create/update/delete | +| `patients` | `PatientController` (خواندن‌ها via `scope()` → بدون `view` هیچ پرونده‌ای؛ افزودن/ویرایشِ زیرآیتم‌ها = `update`؛ **حذفِ** یادداشت/سند/رکورد/تماس/پیام = `delete` — جدا از `update`) | view/create/update/delete | | `payments` | `PaymentController::myPayments`, `PaymentMethodController` (bank/pos), `PatientController` (کیف‌پول + پرداختِ جلسه) | view/create/update/delete | | `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete | | `inventory` | `InventoryController` (items + packages) | view/create/update/delete | diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index a80637d2..d5e11319 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -47,6 +47,43 @@ class MyAppointmentsController extends BaseController private readonly VisitPriceRequirementResolver $visitPriceResolver, ) {} + /** + * پزشکانِ در دسترسِ کاربرِ پنل — برای تب‌ها/تایم‌لاینِ نوبت‌ها. برخلاف + * /clinic/doctor-list (که عمومی است و همهٔ پزشکانِ کلینیک را می‌دهد)، این + * اندپوینت احرازشده است: منشی فقط پزشکانِ تخصیص‌یافتهٔ خودش را می‌گیرد، پس تب‌ها + * دقیقاً با مجوزِ نوبت‌دهی‌اش هم‌راستا می‌شوند. + */ + #[Route('/api/v1/my/clinic-doctors', methods: ['GET'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function myClinicDoctors(#[CurrentUser] User $user): JsonResponse + { + $roles = $user->getRoles(); + $doctors = []; + + if (in_array('ROLE_SECRETARY', $roles, true)) { + $filter = $this->resolveSecretaryFilter($user); + if ($filter !== null) { + [$type, $value] = $filter; + $doctors = $type === 'clinic' + ? ($value === [] ? [] : $this->doctorRepo->findBy(['id' => $value])) + : [$value]; + } + } elseif (in_array('ROLE_CLINIC', $roles, true)) { + $clinic = $this->clinicRepo->findByUser($user); + $doctors = $clinic !== null ? $clinic->getDoctors()->toArray() : []; + } elseif (in_array('ROLE_DOCTOR', $roles, true)) { + $doctor = $this->doctorRepo->findByUser($user); + $doctors = $doctor !== null ? [$doctor] : []; + } + + $data = array_map( + static fn(Doctor $d) => ['uuid' => $d->getUuid(), 'name' => $d->getName()], + $doctors, + ); + + return $this->success(['data' => $data]); + } + #[Route('/api/v1/my/appointment', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function createAppointment(Request $request, #[CurrentUser] User $user): JsonResponse diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index 75065f6a..d2e15c49 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -333,6 +333,9 @@ class ClinicController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } + // این اندپوینت روی firewallِ عمومی است (سایت هم بی‌توکن مصرفش می‌کند) → اینجا + // کاربر احراز نمی‌شود. محدودسازیِ منشی به پزشکانِ تخصیص‌یافته در اندپوینتِ + // احرازشدهٔ پنل انجام می‌شود: GET /api/v1/my/clinic-doctors. $result = $this->doctorRepo->findByClinicWithFilters((int) $clinic->getId(), $request->query->all()); $clinicDoctors = $result['items']; diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index d79ebb1e..d31acf0e 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -276,7 +276,7 @@ class PatientController extends BaseController #[Route('/api/v1/patient/call/{uuid}', methods: ['DELETE'])] public function deleteCall(string $uuid, #[CurrentUser] User $user): JsonResponse { - $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); + $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $call = $this->callRepo->findByUuid($uuid); if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId, $user)) { @@ -334,7 +334,7 @@ class PatientController extends BaseController #[Route('/api/v1/patient/message/{uuid}', methods: ['DELETE'])] public function deleteMessage(string $uuid, #[CurrentUser] User $user): JsonResponse { - $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); + $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $message = $this->messageRepo->findByUuid($uuid); if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId, $user)) { @@ -418,7 +418,7 @@ class PatientController extends BaseController #[Route('/api/v1/patient/note/{uuid}', methods: ['DELETE'])] public function deleteNote(string $uuid, #[CurrentUser] User $user): JsonResponse { - $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); + $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $note = $this->noteRepo->findByUuid($uuid); if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) { @@ -506,7 +506,7 @@ class PatientController extends BaseController #[Route('/api/v1/patient/medical-record/{uuid}', methods: ['DELETE'])] public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse { - $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); + $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $medical = $this->medicalRepo->findByUuid($uuid); if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) { @@ -561,7 +561,7 @@ class PatientController extends BaseController #[Route('/api/v1/patient/attachment/{uuid}', methods: ['DELETE'])] public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse { - $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update'); + $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete'); [$entityType, $entityId] = $this->resolveEntity($user); $attachment = $this->attachmentRepo->findByUuid($uuid); if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId, $user)) { diff --git a/src/Secretary/Security/SecretaryAccessChecker.php b/src/Secretary/Security/SecretaryAccessChecker.php index 5f419f9f..3d46bfd0 100644 --- a/src/Secretary/Security/SecretaryAccessChecker.php +++ b/src/Secretary/Security/SecretaryAccessChecker.php @@ -126,6 +126,25 @@ class SecretaryAccessChecker return $relation !== null && $this->permissions->can($relation, $resource, $action); } + /** + * idهای پزشکانِ تخصیص‌یافته به این منشی در این کلینیک — برای محدودکردنِ + * لیست‌هایی که پیش‌فرض همهٔ پزشکانِ کلینیک را برمی‌گردانند. اگر منشی نیست یا + * محیطش این کلینیک نیست → آرایهٔ خالی. + * + * @return int[] + */ + public function assignedClinicDoctorIds(User $user, \App\Clinic\Entity\Clinic $clinic): array + { + if (!$user->hasRole('ROLE_SECRETARY')) { + return []; + } + + return array_map( + static fn(\App\Doctor\Entity\Doctor $d) => $d->getId(), + $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic), + ); + } + /** * آیا منشی در محیطِ فعالِ خود — که باید همین کلینیک باشد — مجاز به resource/action است؟ * برای منابعِ کلینیک‌سطح مثل clinic_doctors که tenant لزوماً کلینیک است. diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php index 137d0fc8..fb0d05ce 100644 --- a/tests/Secretary/SecretaryResourceEnforcementTest.php +++ b/tests/Secretary/SecretaryResourceEnforcementTest.php @@ -226,6 +226,57 @@ class SecretaryResourceEnforcementTest extends ApiTestCase $this->assertSame(200, $this->responseCode()); } + public function testPatientDeleteSeparateFromUpdate(): void + { + // منشی با patients.update ولی بدون patients.delete نباید بتواند حذف کند. + // گیتِ delete پیش از واکشیِ رکورد اجرا می‌شود، پس uuidِ ناموجود هم ۴۰۳ می‌دهد. + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['patients' => ['view' => true, 'update' => true, 'delete' => false]]]); + $this->em->flush(); + + $this->authJson('DELETE', '/api/v1/patient/note/00000000-0000-0000-0000-000000000000', $secretary); + $this->assertSame(403, $this->responseCode(), 'حذف باید جدا از ویرایش کنترل شود'); + } + + public function testPatientDeleteAllowedWhenGranted(): void + { + // با patients.delete، گیت عبور می‌کند و به «یافت نشد» می‌رسد (نه ۴۰۳). + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['patients' => ['view' => true, 'delete' => true]]]); + $this->em->flush(); + + $this->authJson('DELETE', '/api/v1/patient/note/00000000-0000-0000-0000-000000000000', $secretary); + $this->assertSame(404, $this->responseCode()); + } + + public function testDoctorListReturnsOnlyAssignedDoctors(): void + { + // کلینیک با دو پزشک؛ منشی فقط به یکی تخصیص داده شده. + $owner = $this->createUser(['ROLE_CLINIC']); + $clinic = new Clinic($owner); + $this->em->persist($clinic); + + $assigned = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تخصیص‌یافته'); + $unassigned = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر دیگر'); + $this->em->persist($assigned); + $this->em->persist($unassigned); + $clinic->getDoctors()->add($assigned); + $clinic->getDoctors()->add($unassigned); + + $secretary = $this->createUser(['ROLE_SECRETARY']); + $this->em->persist(new DoctorSecretary($assigned, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic)); + $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid())); + $this->em->flush(); + + // اندپوینتِ احرازشدهٔ پنل (نه /clinic/doctor-list که عمومی است). + $body = $this->authJson('GET', '/api/v1/my/clinic-doctors', $secretary); + $this->assertSame(200, $this->responseCode()); + + $names = array_map(static fn($d) => $d['name'], $body['data']['data']); + $this->assertContains('دکتر تخصیص‌یافته', $names); + $this->assertNotContains('دکتر دیگر', $names, 'منشی نباید پزشکِ تخصیص‌نیافته را ببیند'); + } + /** مثل makeClinicSecretary اما clinic و doctor را هم برمی‌گرداند. */ private function makeClinicSecretaryWithClinic(): array {