diff --git a/docs/api/resource.md b/docs/api/resource.md index 6938caa9..606c3d25 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -3,6 +3,13 @@ > **Base:** `/api/v1` · **Auth:** JWT روی همهٔ اندپوینت‌ها > **مجوز:** `appointment_settings` (`view` خواندن، `update` نوشتن) — همان مجوز تنظیمات > نوبت‌دهی؛ مجوز تازه‌ای ساخته نشده. +> +> **استثنای خواندن برای نوبت‌دهی (2026-08):** چهار اندپوینتِ خواندنی که ورودیِ ثبت +> نوبت‌اند — `GET /resources`، `GET /resource/{uuid}/services`، +> `GET /resource/{uuid}/day-slots`، `GET /resource/{uuid}/service-slots` — با +> **`appointments.view` هم** باز می‌شوند. منشی یا پزشکِ عضوی که اجازهٔ ثبت نوبت دارد +> ولی تنظیمات نوبت‌دهی برایش بسته است، وگرنه نمی‌توانست همان نوبتی را که مجاز است +> ثبت کند. نوشتن همچنان فقط `appointment_settings.update`. --- @@ -347,7 +354,7 @@ ] ``` -**دسترسی:** `appointment_settings.view`. **۴۰۴:** منبع محیط دیگر. +**دسترسی:** `appointment_settings.view` یا `appointments.view`. **۴۰۴:** منبع محیط دیگر. ### `PUT /api/v1/resource/{uuid}/services` @@ -476,7 +483,7 @@ ### `GET /api/v1/resource/{uuid}/day-slots` (2026-08) -مجوز: `appointment_settings.view`. پارامتر: `date=Y-m-d` (الزامی). +مجوز: `appointment_settings.view` یا `appointments.view`. پارامتر: `date=Y-m-d` (الزامی). بازه‌های **کاری** منبع در یک روز — ورودیِ تایم‌لاینِ سرویسیِ صفحهٔ نوبت‌ها. معادلِ `appointment-slots` پزشک، ولی از تقویم خودِ منبع: ساعت شعبه ∩ شیفت منبع − تعطیلات − @@ -499,7 +506,7 @@ ### `GET /api/v1/resource/{uuid}/service-slots` (2026-08) -مجوز: `appointment_settings.view`. +مجوز: `appointment_settings.view` یا `appointments.view`. | پارامتر | توضیح | |---|---| diff --git a/src/Resource/Controller/ResourceBookingSlotController.php b/src/Resource/Controller/ResourceBookingSlotController.php index fb6aabf7..42c43d3b 100644 --- a/src/Resource/Controller/ResourceBookingSlotController.php +++ b/src/Resource/Controller/ResourceBookingSlotController.php @@ -44,7 +44,7 @@ class ResourceBookingSlotController extends BaseController #[Route('/api/v1/resource/{uuid}/day-slots', name: 'resource_day_slots', methods: ['GET'])] public function daySlots(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { - $this->denyUnlessGranted($user, 'view'); + $this->denyUnlessGrantedForBooking($user); $resource = $this->context->resource($user, $uuid); $date = $this->requireDate($request); @@ -77,7 +77,7 @@ class ResourceBookingSlotController extends BaseController #[Route('/api/v1/resource/{uuid}/service-slots', name: 'resource_service_slots', methods: ['GET'])] public function serviceSlots(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse { - $this->denyUnlessGranted($user, 'view'); + $this->denyUnlessGrantedForBooking($user); $resource = $this->context->resource($user, $uuid); $date = $this->requireDate($request); diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php index bfa309e9..662cb776 100644 --- a/src/Resource/Controller/ResourceController.php +++ b/src/Resource/Controller/ResourceController.php @@ -40,7 +40,7 @@ class ResourceController extends BaseController #[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])] public function list(#[CurrentUser] User $user, Request $request): JsonResponse { - $this->denyUnlessGranted($user, 'view'); + $this->denyUnlessGrantedForBooking($user); [$entityType, $entityId] = $this->context->pair($user); @@ -155,7 +155,7 @@ class ResourceController extends BaseController #[Route('/api/v1/resource/{uuid}/services', name: 'resource_services_list', methods: ['GET'])] public function services(#[CurrentUser] User $user, string $uuid): JsonResponse { - $this->denyUnlessGranted($user, 'view'); + $this->denyUnlessGrantedForBooking($user); return $this->success($this->serviceOfferings->listFor($this->context->resource($user, $uuid))); } diff --git a/src/Resource/Controller/ResourcePermissionTrait.php b/src/Resource/Controller/ResourcePermissionTrait.php index 1ec61c3c..1075ac11 100644 --- a/src/Resource/Controller/ResourcePermissionTrait.php +++ b/src/Resource/Controller/ResourcePermissionTrait.php @@ -5,6 +5,8 @@ namespace App\Resource\Controller; use App\Auth\Entity\User; use App\Clinic\Security\ClinicDoctorAccessChecker; use App\Secretary\Security\SecretaryAccessChecker; +use App\Shared\Constant\ErrorCodes; +use App\Shared\Exception\AppException; use Symfony\Contracts\Service\Attribute\Required; /** @@ -34,4 +36,26 @@ trait ResourcePermissionTrait $this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action); $this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action); } + + /** + * خواندنِ منبع **برای نوبت‌دهی** — نه برای پیکربندی‌اش. + * + * فهرست منابع، سرویس‌هایشان و وقت‌های آزادشان ورودیِ ثبت نوبت‌اند، پس مجوزِ + * `appointments.view` هم برایشان کافی است: منشی‌ای که اجازهٔ ثبت نوبت دارد ولی + * تنظیمات نوبت‌دهی برایش بسته است، وگرنه نمی‌توانست همان نوبتی را که مجاز است + * ثبت کند — ۴۰۳ روی خواندنِ فهرست، کلِ نوبت‌دهی منبع‌محور را برایش می‌بست. + * نوشتن همچنان فقط با `appointment_settings.update`. + */ + private function denyUnlessGrantedForBooking(User $user): void + { + $allowed = + ($this->secretaryAccess->canOrNonSecretary($user, 'appointment_settings', 'view') + && $this->clinicDoctorAccess->canOrNonMember($user, 'appointment_settings', 'view')) + || ($this->secretaryAccess->canOrNonSecretary($user, 'appointments', 'view') + && $this->clinicDoctorAccess->canOrNonMember($user, 'appointments', 'view')); + + if (!$allowed) { + throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403); + } + } } diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php index 6971d7d9..6276937e 100644 --- a/tests/Secretary/SecretaryResourceEnforcementTest.php +++ b/tests/Secretary/SecretaryResourceEnforcementTest.php @@ -226,6 +226,52 @@ class SecretaryResourceEnforcementTest extends ApiTestCase $this->assertSame(200, $this->responseCode()); } + /** + * فهرست منابع ورودیِ ثبت نوبت است: منشیِ دارای `appointments.view` — که پیش‌فرض + * است — باید بتواند بخواندش، حتی وقتی `appointment_settings.view` بسته است. + * وگرنه نوبت‌دهی منبع‌محور برایش کاملاً بسته می‌شد. + */ + public function testResourceListReadableWithAppointmentsPermissionOnly(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => [ + 'appointments' => ['view' => true], + 'appointment_settings' => ['view' => false, 'update' => false], + ]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/resources', $secretary); + $this->assertSame(200, $this->responseCode()); + } + + /** نوشتن همچنان فقط با تنظیمات نوبت‌دهی — مجوز نوبت‌ها درش را باز نمی‌کند. */ + public function testResourceWriteStillNeedsAppointmentSettings(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => [ + 'appointments' => ['view' => true, 'create' => true], + 'appointment_settings' => ['view' => false, 'update' => false], + ]]); + $this->em->flush(); + + $this->authJson('POST', '/api/v1/resource', $secretary, ['type_uuid' => 'x']); + $this->assertSame(403, $this->responseCode()); + } + + /** بدون هیچ‌کدام از دو مجوز، فهرست منابع همچنان ۴۰۳ است. */ + public function testResourceListDeniedWithoutAnyPermission(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => [ + 'appointments' => ['view' => false], + 'appointment_settings' => ['view' => false], + ]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/resources', $secretary); + $this->assertSame(403, $this->responseCode()); + } + public function testSubscriptionDeniedByDefault(): void { [$secretary] = $this->makeClinicSecretary();