From e8bf2ce9b1643f5622738aad31f04b281e475f64 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 23 Jul 2026 17:29:51 +0330 Subject: [PATCH] feat(secretary): grant staff/discounts/sms/appointment_settings/clinic_doctors (phase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the secretary permission system to five previously owner-only modules, so a clinic/doctor can delegate each page to a secretary. All were unreachable by secretaries before (role-based tenant resolution returned "unknown" → 403). New permission resources (default-deny, three-place add: entity default, SecretaryPermissions type, both MySecretariesPage + admin SecretariesPage): staff, discounts, sms, appointment_settings (view/update only), clinic_doctors (clinic-only — hidden from independent doctors via `clinicOnly` section filter). Backend enforcement (SecretaryAccessChecker, three new reusable helpers): - resolveOwnerEntity(): owner pair from active context — used by StaffController, DiscountController, SmsWalletController (now secretary-aware resolveEntity). - canForDoctor(): per-doctor-scoped check (assigned doctor + toggle) — wired into AppointmentSettingsController::denyDoctorAccess. - canForClinic(): clinic-scoped check — wired into ClinicController::detachDoctor, ClinicDoctorPermissionController (view/update), ClinicInvitationController (create/view/update/delete). clinic_doctors is clinic-context only. Guards run ahead of any subscription gate; non-secretary roles pass unchanged. Frontend: - RoleRoute: staff, discounts, sms-wallet, appointment-settings (doctor+clinic variants), settings/clinic-doctors routes accept secretary + permission gate. - Sidebar (secretary branch): five new items gated by can(); appointment_settings route follows active scope; clinic_doctors only in clinic scope. Tests: SecretaryResourceEnforcementTest — denied-by-default + allowed-when-granted for all five (18 total). Sidebar.test — B-resource gating + clinic_doctors scope rule. docs/api/secretary.md resource list, enforcement map, JSON example updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/admin/App.tsx | 12 +- .../admin/components/layout/Sidebar.test.tsx | 39 ++++++ assets/admin/components/layout/Sidebar.tsx | 41 ++++++ assets/admin/pages/MySecretariesPage.tsx | 65 ++++++++- assets/admin/pages/SecretariesPage.tsx | 48 +++++++ assets/admin/types/index.ts | 28 ++++ docs/api/secretary.md | 14 +- .../AppointmentSettingsController.php | 7 + src/Clinic/Controller/ClinicController.php | 4 +- .../ClinicDoctorPermissionController.php | 13 +- .../Controller/ClinicInvitationController.php | 17 ++- .../Controller/DiscountController.php | 10 ++ src/Secretary/Entity/DoctorSecretary.php | 5 + .../Security/SecretaryAccessChecker.php | 52 +++++++ src/Sms/Controller/SmsWalletController.php | 11 ++ src/Staff/Controller/StaffController.php | 12 ++ .../SecretaryResourceEnforcementTest.php | 130 ++++++++++++++++++ 17 files changed, 486 insertions(+), 22 deletions(-) diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 7854ad91..0c093f5d 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -209,8 +209,8 @@ export default function App() { } /> {/* پزشکان کلینیک — تب تنظیماتِ مالک کلینیک */} - } /> - } /> + } /> + } /> {/* مسیر قدیمی «مدیریت مطب» → ریدایرکت به تب جدید */} } /> @@ -248,18 +248,18 @@ export default function App() { } /> {/* فاز ۲ — دکتر / کلینیک */} - } /> + } /> } /> } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> } /> - } /> + } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.test.tsx b/assets/admin/components/layout/Sidebar.test.tsx index 4f682c35..78fb6d58 100644 --- a/assets/admin/components/layout/Sidebar.test.tsx +++ b/assets/admin/components/layout/Sidebar.test.tsx @@ -116,4 +116,43 @@ describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () => "/admin/tags-settings", ); }); + + it("منابع فاز B (staff/discounts/sms/appointment_settings) با مجوز نمایش داده می‌شوند", () => { + setSecretary({ + staff: { view: true }, + discounts: { view: true }, + sms: { view: true }, + appointment_settings: { 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(); + }); + + 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 23d03586..8c16a0e0 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -18,6 +18,7 @@ import { KeyIcon, LockClosedIcon, PlusIcon, + ReceiptPercentIcon, ShieldCheckIcon, StarIcon, TagIcon, @@ -422,6 +423,46 @@ function buildSections( 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: "پزشکان کلینیک", + }); + } return [ { diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index 077f0713..9ce5d168 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -76,6 +76,11 @@ const EMPTY_PERMISSIONS: SecretaryPermissions = { 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 }, }; type PermSection = keyof SecretaryPermissions; @@ -83,6 +88,8 @@ type PermSection = keyof SecretaryPermissions; const PERMISSION_SECTIONS: { key: PermSection; title: string; + /** فقط برای مالکِ کلینیک نمایش داده می‌شود (پزشک مستقل نه toggle نه منو). */ + clinicOnly?: boolean; items: { key: string; label: string }[]; }[] = [ { @@ -173,16 +180,67 @@ const PERMISSION_SECTIONS: { { key: "delete", label: "حذف خدمت" }, ], }, + { + key: "staff", + title: "پرسنل", + items: [ + { key: "view", label: "مشاهده پرسنل" }, + { key: "create", label: "افزودن پرسنل" }, + { key: "update", label: "ویرایش پرسنل" }, + { key: "delete", label: "حذف پرسنل" }, + ], + }, + { + key: "discounts", + title: "تخفیف‌ها", + items: [ + { key: "view", label: "مشاهده تخفیف‌ها" }, + { key: "create", label: "ایجاد تخفیف" }, + { key: "update", label: "ویرایش تخفیف" }, + { key: "delete", label: "حذف تخفیف" }, + ], + }, + { + key: "sms", + title: "پیامک‌ها", + items: [ + { key: "view", label: "مشاهده پیامک/کیف پول" }, + { key: "create", label: "شارژ/ارسال" }, + { key: "update", label: "ویرایش تنظیمات" }, + { key: "delete", label: "حذف" }, + ], + }, + { + key: "appointment_settings", + title: "تنظیمات نوبت‌دهی", + items: [ + { key: "view", label: "مشاهده تنظیمات" }, + { key: "update", label: "ویرایش تنظیمات" }, + ], + }, + { + key: "clinic_doctors", + title: "مدیریت پزشکان کلینیک", + clinicOnly: true, + items: [ + { key: "view", label: "مشاهده پزشکان" }, + { key: "create", label: "افزودن پزشک" }, + { key: "update", label: "ویرایش پزشک" }, + { key: "delete", label: "حذف پزشک" }, + ], + }, ]; function PermissionAccordions({ permissions, onChange, disabled, + isClinic, }: { permissions: SecretaryPermissions; onChange: (section: PermSection, item: string, value: boolean) => void; disabled?: boolean; + isClinic: boolean; }) { const [openKeys, setOpenKeys] = useState>( new Set(["appointments", "patients"]), @@ -196,13 +254,16 @@ function PermissionAccordions({ }); }; + // منابع clinicOnly (مثل مدیریت پزشکان کلینیک) فقط برای مالکِ کلینیک دیده می‌شوند. + const sections = PERMISSION_SECTIONS.filter((s) => !s.clinicOnly || isClinic); + return (

مجوزهای دسترسی

- {PERMISSION_SECTIONS.map((section) => { + {sections.map((section) => { const open = openKeys.has(section.key); const sectionPerm = permissions[section.key] as Record; return ( @@ -490,7 +551,7 @@ function SecretaryModal({ setField("address", v)} disabled={disabled} multiline rows={2} />
- +
); diff --git a/assets/admin/pages/SecretariesPage.tsx b/assets/admin/pages/SecretariesPage.tsx index 739e2322..84898365 100644 --- a/assets/admin/pages/SecretariesPage.tsx +++ b/assets/admin/pages/SecretariesPage.tsx @@ -22,6 +22,11 @@ const DEFAULT_PERMISSIONS: SecretaryPermissions = { 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 }, }; type PermSection = keyof SecretaryPermissions; @@ -106,6 +111,49 @@ const PERMISSION_LABELS: RecordhasRole('ROLE_SECRETARY') + && $this->secretaryAccess->canForDoctor($user, $doctor, $clinic, 'appointment_settings', $action)) { + return null; + } + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index 47f73e01..75065f6a 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -47,6 +47,7 @@ class ClinicController extends BaseController private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly FileValidatorService $fileValidator, private readonly \App\Representation\Service\DomainContextResolver $domainResolver, + private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, private readonly string $projectDir, ) {} @@ -383,7 +384,8 @@ class ClinicController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } - if (!$this->canManageClinic($clinic, $user)) { + if (!$this->canManageClinic($clinic, $user) + && !$this->secretaryAccess->canForClinic($user, $clinic, 'clinic_doctors', 'delete')) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی مجاز نیست', 403); } diff --git a/src/Clinic/Controller/ClinicDoctorPermissionController.php b/src/Clinic/Controller/ClinicDoctorPermissionController.php index 4570b43f..7d9841b4 100644 --- a/src/Clinic/Controller/ClinicDoctorPermissionController.php +++ b/src/Clinic/Controller/ClinicDoctorPermissionController.php @@ -27,13 +27,14 @@ class ClinicDoctorPermissionController extends BaseController private readonly DoctorRepository $doctorRepo, private readonly ClinicDoctorPermissionRepository $permRepo, private readonly EntityManagerInterface $em, + private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, ) {} #[Route('/api/v1/admin/clinic/{clinicUuid}/doctor-permissions', methods: ['GET'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function listPermissions(string $clinicUuid, #[CurrentUser] User $user): JsonResponse { - $clinic = $this->resolveClinic($clinicUuid, $user); + $clinic = $this->resolveClinic($clinicUuid, $user, 'view'); $data = array_map( fn(ClinicDoctorPermission $p) => $p->toArray(), @@ -47,7 +48,7 @@ class ClinicDoctorPermissionController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function showPermissions(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse { - $clinic = $this->resolveClinic($clinicUuid, $user); + $clinic = $this->resolveClinic($clinicUuid, $user, 'view'); $doctor = $this->resolveMember($clinic, $doctorUuid); return $this->success($this->permRepo->getOrCreate($clinic, $doctor)->toArray()); @@ -57,7 +58,7 @@ class ClinicDoctorPermissionController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function updatePermissions(string $clinicUuid, string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse { - $clinic = $this->resolveClinic($clinicUuid, $user); + $clinic = $this->resolveClinic($clinicUuid, $user, 'update'); $doctor = $this->resolveMember($clinic, $doctorUuid); $perm = $this->permRepo->getOrCreate($clinic, $doctor); @@ -79,7 +80,7 @@ class ClinicDoctorPermissionController extends BaseController return $this->success($perm->toArray()); } - private function resolveClinic(string $clinicUuid, User $user): Clinic + private function resolveClinic(string $clinicUuid, User $user, string $action): Clinic { $clinic = $this->clinicRepo->findByUuid($clinicUuid); if ($clinic === null) { @@ -87,7 +88,9 @@ class ClinicDoctorPermissionController extends BaseController } $isOwner = $clinic->getUser()->getId() === $user->getId(); - if (!$user->hasRole('ROLE_ADMIN') && !$isOwner) { + // منشیِ همان کلینیک با توگلِ clinic_doctors می‌تواند مدیریت پزشکان را انجام دهد. + $isSecretary = $this->secretaryAccess->canForClinic($user, $clinic, 'clinic_doctors', $action); + if (!$user->hasRole('ROLE_ADMIN') && !$isOwner && !$isSecretary) { throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403); } diff --git a/src/ClinicInvitation/Controller/ClinicInvitationController.php b/src/ClinicInvitation/Controller/ClinicInvitationController.php index d159161a..272da135 100644 --- a/src/ClinicInvitation/Controller/ClinicInvitationController.php +++ b/src/ClinicInvitation/Controller/ClinicInvitationController.php @@ -26,6 +26,7 @@ class ClinicInvitationController extends BaseController private readonly ClinicDoctorInvitationRepository $invRepo, private readonly ClinicRepository $clinicRepo, private readonly DoctorRepository $doctorRepo, + private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, ) {} // ── Admin endpoints ────────────────────────────────────────────────────── @@ -39,7 +40,7 @@ class ClinicInvitationController extends BaseController throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404); } - $this->assertClinicAccess($clinic, $user); + $this->assertClinicAccess($clinic, $user, 'create'); $body = json_decode($request->getContent(), true) ?? []; $mobile = trim($body['mobile'] ?? ''); @@ -64,7 +65,7 @@ class ClinicInvitationController extends BaseController throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404); } - $this->assertClinicAccess($clinic, $user); + $this->assertClinicAccess($clinic, $user, 'view'); $page = max(1, (int) $request->query->get('page', 1)); $limit = min(50, max(1, (int) $request->query->get('limit', 20))); @@ -96,7 +97,7 @@ class ClinicInvitationController extends BaseController throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); } - $this->assertClinicAccess($inv->getClinic(), $user); + $this->assertClinicAccess($inv->getClinic(), $user, 'create'); $this->invitationService->resend($inv); return $this->success(['message' => 'پیامک مجدداً ارسال شد']); @@ -111,7 +112,7 @@ class ClinicInvitationController extends BaseController throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); } - $this->assertClinicAccess($inv->getClinic(), $user); + $this->assertClinicAccess($inv->getClinic(), $user, 'update'); $body = json_decode($request->getContent(), true) ?? []; $status = $body['status'] ?? ''; @@ -130,7 +131,7 @@ class ClinicInvitationController extends BaseController throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404); } - $this->assertClinicAccess($inv->getClinic(), $user); + $this->assertClinicAccess($inv->getClinic(), $user, 'delete'); $this->invitationService->delete($inv); return $this->success(['message' => 'دعوتنامه حذف شد']); @@ -193,7 +194,7 @@ class ClinicInvitationController extends BaseController throw new AppException('ERR_VALIDATION_001', 'action باید accept یا reject باشد', 422); } - private function assertClinicAccess(\App\Clinic\Entity\Clinic $clinic, User $user): void + private function assertClinicAccess(\App\Clinic\Entity\Clinic $clinic, User $user, string $action = 'view'): void { if ($user->hasRole('ROLE_ADMIN')) { return; @@ -201,6 +202,10 @@ class ClinicInvitationController extends BaseController if ($user->hasRole('ROLE_CLINIC') && $clinic->getUser()->getId() === $user->getId()) { return; } + // منشیِ همان کلینیک با توگلِ clinic_doctors می‌تواند پزشکان را دعوت/مدیریت کند. + if ($this->secretaryAccess->canForClinic($user, $clinic, 'clinic_doctors', $action)) { + return; + } throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403); } diff --git a/src/Discount/Controller/DiscountController.php b/src/Discount/Controller/DiscountController.php index 95e8451f..0c80be8b 100644 --- a/src/Discount/Controller/DiscountController.php +++ b/src/Discount/Controller/DiscountController.php @@ -9,6 +9,7 @@ use App\Discount\Repository\DiscountRuleRepository; use App\Discount\Service\DiscountEngine; use App\Doctor\Repository\DoctorRepository; use App\Patient\Repository\PatientSessionRepository; +use App\Secretary\Security\SecretaryAccessChecker; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -25,6 +26,7 @@ class DiscountController extends BaseController private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, private readonly PatientSessionRepository $sessionRepo, + private readonly SecretaryAccessChecker $secretaryAccess, ) {} /** @return array{0: string, 1: ?int} */ @@ -36,6 +38,10 @@ class DiscountController extends BaseController if ($user->hasRole('ROLE_CLINIC')) { return ['clinic', $this->clinicRepo->findByUser($user)?->getId()]; } + // منشی روی tenantِ محیطِ فعال؛ مجوز جدا با denyUnlessGranted. + if ($user->hasRole('ROLE_SECRETARY')) { + return $this->secretaryAccess->resolveOwnerEntity($user); + } return ['unknown', null]; } @@ -45,6 +51,7 @@ class DiscountController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function list(#[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'discounts', 'view'); [$ownerType, $ownerId] = $this->resolveOwner($user); if ($ownerId === null) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); @@ -57,6 +64,7 @@ class DiscountController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function create(Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'discounts', 'create'); [$ownerType, $ownerId] = $this->resolveOwner($user); if ($ownerId === null) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); @@ -84,6 +92,7 @@ class DiscountController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'discounts', 'update'); [$ownerType, $ownerId] = $this->resolveOwner($user); if ($ownerId === null) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); @@ -117,6 +126,7 @@ class DiscountController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'discounts', 'delete'); [$ownerType, $ownerId] = $this->resolveOwner($user); if ($ownerId === null) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); diff --git a/src/Secretary/Entity/DoctorSecretary.php b/src/Secretary/Entity/DoctorSecretary.php index e2e71305..dd7cb839 100644 --- a/src/Secretary/Entity/DoctorSecretary.php +++ b/src/Secretary/Entity/DoctorSecretary.php @@ -29,6 +29,11 @@ class DoctorSecretary '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], ], ]; diff --git a/src/Secretary/Security/SecretaryAccessChecker.php b/src/Secretary/Security/SecretaryAccessChecker.php index 9ce8e338..5f419f9f 100644 --- a/src/Secretary/Security/SecretaryAccessChecker.php +++ b/src/Secretary/Security/SecretaryAccessChecker.php @@ -85,6 +85,58 @@ class SecretaryAccessChecker return ['unknown', null]; } + /** + * آیا منشی در محیطِ فعالِ خود، روی این پزشکِ مشخص (و کلینیکِ همان نوبت/تنظیم) + * مجاز به resource/action است؟ ترکیبِ «اسکوپِ پزشکِ تخصیص‌یافته» و «توگلِ مجوز». + * قرینهٔ AppointmentAccessChecker::secretaryCan اما برای هر resource. + */ + public function canForDoctor( + User $user, + \App\Doctor\Entity\Doctor $doctor, + ?\App\Clinic\Entity\Clinic $clinic, + string $resource, + string $action + ): bool { + $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); + if ($dbUuid === null) { + return false; + } + + $ctxClinic = $this->clinicRepo->findByUuid($dbUuid); + if ($ctxClinic !== null) { + // محیطِ کلینیک: تنظیم باید در همان کلینیک باشد و پزشکش جزو پزشکانِ منشی. + if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) { + return false; + } + $relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor); + + return $relation !== null && $this->permissions->can($relation, $resource, $action); + } + + // محیطِ مطب شخصی: تنظیم هم باید شخصی باشد (clinic == null). + if ($clinic !== null) { + return false; + } + $ctxDoctor = $this->doctorRepo->findByUuid($dbUuid); + if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) { + return false; + } + $relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); + + return $relation !== null && $this->permissions->can($relation, $resource, $action); + } + + /** + * آیا منشی در محیطِ فعالِ خود — که باید همین کلینیک باشد — مجاز به resource/action است؟ + * برای منابعِ کلینیک‌سطح مثل clinic_doctors که tenant لزوماً کلینیک است. + */ + public function canForClinic(User $user, \App\Clinic\Entity\Clinic $clinic, string $resource, string $action): bool + { + [$type, $id] = $this->resolveOwnerEntity($user); + + return $type === 'clinic' && $id === $clinic->getId() && $this->can($user, $resource, $action); + } + /** * برای مسیرهایی که چند نقش دارند: فقط منشی را محدود کن. سایر نقش‌ها true. */ diff --git a/src/Sms/Controller/SmsWalletController.php b/src/Sms/Controller/SmsWalletController.php index aef5af64..614d5a4e 100644 --- a/src/Sms/Controller/SmsWalletController.php +++ b/src/Sms/Controller/SmsWalletController.php @@ -38,6 +38,7 @@ class SmsWalletController extends BaseController private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, private readonly \App\Config\Repository\SiteConfigRepository $configRepo, + private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, private readonly string $appBaseUrl, ) {} @@ -50,6 +51,7 @@ class SmsWalletController extends BaseController #[Route('/api/v1/sms/wallet/balance', methods: ['GET'])] public function balance(#[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'sms', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); @@ -69,6 +71,7 @@ class SmsWalletController extends BaseController #[Route('/api/v1/sms/wallet/charge', methods: ['POST'])] public function charge(Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'sms', 'create'); [$entityType, $entityId] = $this->resolveEntity($user); if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); @@ -102,6 +105,7 @@ class SmsWalletController extends BaseController #[Route('/api/v1/sms/wallet/logs', methods: ['GET'])] public function logs(Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'sms', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); @@ -125,6 +129,7 @@ class SmsWalletController extends BaseController #[Route('/api/v1/sms/settings', methods: ['GET'])] public function getSettings(#[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'sms', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); @@ -149,6 +154,7 @@ class SmsWalletController extends BaseController #[Route('/api/v1/sms/settings', methods: ['PATCH'])] public function updateSettings(Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'sms', 'update'); [$entityType, $entityId] = $this->resolveEntity($user); if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); @@ -281,6 +287,11 @@ class SmsWalletController extends BaseController return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; } + // منشی روی tenantِ محیطِ فعال؛ مجوز جدا با denyUnlessGranted. + if ($user->hasRole('ROLE_SECRETARY')) { + return $this->secretaryAccess->resolveOwnerEntity($user); + } + return ['unknown', null]; } } diff --git a/src/Staff/Controller/StaffController.php b/src/Staff/Controller/StaffController.php index 58768540..f6649e06 100644 --- a/src/Staff/Controller/StaffController.php +++ b/src/Staff/Controller/StaffController.php @@ -5,6 +5,7 @@ namespace App\Staff\Controller; use App\Auth\Entity\User; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; +use App\Secretary\Security\SecretaryAccessChecker; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\Staff\Entity\ClinicStaff; @@ -24,11 +25,13 @@ class StaffController extends BaseController private readonly ClinicStaffRepository $staffRepo, private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, + private readonly SecretaryAccessChecker $secretaryAccess, ) {} #[Route('/api/v1/staff', methods: ['GET'])] public function list(#[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'staff', 'view'); [$entityType, $entityId] = $this->resolveEntity($user); if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); @@ -45,6 +48,7 @@ class StaffController extends BaseController #[Route('/api/v1/staff', methods: ['POST'])] public function create(Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'staff', 'create'); [$entityType, $entityId] = $this->resolveEntity($user); if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); @@ -71,6 +75,7 @@ class StaffController extends BaseController #[Route('/api/v1/staff/{uuid}', methods: ['PATCH'])] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'staff', 'update'); $staff = $this->staffRepo->findByUuid($uuid); if ($staff === null) { return $this->error(ErrorCodes::ERR_STAFF_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_STAFF_NOT_FOUND), 404); @@ -98,6 +103,7 @@ class StaffController extends BaseController #[Route('/api/v1/staff/{uuid}/toggle', methods: ['PATCH'])] public function toggle(string $uuid, #[CurrentUser] User $user): JsonResponse { + $this->secretaryAccess->denyUnlessGranted($user, 'staff', 'update'); $staff = $this->staffRepo->findByUuid($uuid); if ($staff === null) { return $this->error(ErrorCodes::ERR_STAFF_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_STAFF_NOT_FOUND), 404); @@ -125,6 +131,12 @@ class StaffController extends BaseController return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; } + // منشی روی tenantِ محیطِ فعالِ خود (کلینیک/پزشک) عمل می‌کند؛ مجوز جدا با + // denyUnlessGranted بررسی شده است. + if ($user->hasRole('ROLE_SECRETARY')) { + return $this->secretaryAccess->resolveOwnerEntity($user); + } + return ['unknown', null]; } diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php index ab2ba2ff..137d0fc8 100644 --- a/tests/Secretary/SecretaryResourceEnforcementTest.php +++ b/tests/Secretary/SecretaryResourceEnforcementTest.php @@ -114,4 +114,134 @@ class SecretaryResourceEnforcementTest extends ApiTestCase $this->authJson('POST', '/api/v1/service-section', $secretary, ['name' => 'بخش تست']); $this->assertSame(403, $this->responseCode()); } + + // ── Phase B resources ───────────────────────────────────────────────────── + + public function testStaffDeniedByDefault(): void + { + [$secretary] = $this->makeClinicSecretary(); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/staff', $secretary); + $this->assertSame(403, $this->responseCode()); + } + + public function testStaffAllowedWhenGranted(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['staff' => ['view' => true]]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/staff', $secretary); + $this->assertSame(200, $this->responseCode()); + } + + public function testStaffCreateDeniedButViewGranted(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['staff' => ['view' => true, 'create' => false]]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/staff', $secretary); + $this->assertSame(200, $this->responseCode()); + + $this->authJson('POST', '/api/v1/staff', $secretary, ['full_name' => 'خانم تست']); + $this->assertSame(403, $this->responseCode()); + } + + public function testDiscountsDeniedByDefault(): void + { + [$secretary] = $this->makeClinicSecretary(); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/admin/discount-rules', $secretary); + $this->assertSame(403, $this->responseCode()); + } + + public function testDiscountsAllowedWhenGranted(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['discounts' => ['view' => true]]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/admin/discount-rules', $secretary); + $this->assertSame(200, $this->responseCode()); + } + + public function testSmsDeniedByDefault(): void + { + [$secretary] = $this->makeClinicSecretary(); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/sms/wallet/balance', $secretary); + $this->assertSame(403, $this->responseCode()); + } + + public function testSmsAllowedWhenGranted(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['sms' => ['view' => true]]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/sms/wallet/balance', $secretary); + $this->assertSame(200, $this->responseCode()); + } + + public function testClinicDoctorsDeniedByDefault(): void + { + [$secretary, , $clinic] = $this->makeClinicSecretaryWithClinic(); + $this->em->flush(); + + $this->authJson('GET', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor-permissions", $secretary); + $this->assertSame(403, $this->responseCode()); + } + + public function testClinicDoctorsAllowedWhenGranted(): void + { + [$secretary, $rel, $clinic] = $this->makeClinicSecretaryWithClinic(); + $rel->mergePermissions(['resources' => ['clinic_doctors' => ['view' => true]]]); + $this->em->flush(); + + $this->authJson('GET', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor-permissions", $secretary); + $this->assertSame(200, $this->responseCode()); + } + + public function testAppointmentSettingsDeniedByDefault(): void + { + [$secretary, , $clinic, $doctor] = $this->makeClinicSecretaryWithClinic(); + $this->em->flush(); + + // clinic_uuid لازم است تا محیطِ کلینیک حل شود (مثل پزشکِ عضو کلینیک). + $this->authJson('GET', "/api/v1/appointment-settings/holidays/list/{$doctor->getUuid()}?clinic_uuid={$clinic->getUuid()}", $secretary); + $this->assertSame(403, $this->responseCode()); + } + + public function testAppointmentSettingsAllowedWhenGranted(): void + { + [$secretary, $rel, $clinic, $doctor] = $this->makeClinicSecretaryWithClinic(); + $rel->mergePermissions(['resources' => ['appointment_settings' => ['view' => true]]]); + $this->em->flush(); + + $this->authJson('GET', "/api/v1/appointment-settings/holidays/list/{$doctor->getUuid()}?clinic_uuid={$clinic->getUuid()}", $secretary); + $this->assertSame(200, $this->responseCode()); + } + + /** مثل makeClinicSecretary اما clinic و doctor را هم برمی‌گرداند. */ + private function makeClinicSecretaryWithClinic(): array + { + $owner = $this->createUser(['ROLE_CLINIC']); + $clinic = new Clinic($owner); + $this->em->persist($clinic); + + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); + $this->em->persist($doctor); + $clinic->getDoctors()->add($doctor); + + $secretary = $this->createUser(['ROLE_SECRETARY']); + $rel = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic); + $this->em->persist($rel); + $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid())); + + return [$secretary, $rel, $clinic, $doctor]; + } }