feat(clinic-doctor): full permission coverage + enforcement, parity with secretary
The clinic-member-doctor permission system (ClinicDoctorPermission) lagged the secretary system: only 6 resources, enforced in ~6 places, dead toggles (services.update never checked), and a sidebar showing just appointments+patients. Bring it to parity so a clinic owner can control exactly what each member doctor does — while an independent doctor stays completely unrestricted. Coverage: add insurances, addresses, inventory, tags, staff, discounts, sms to ClinicDoctorPermission::DEFAULT_PERMISSIONS + DoctorPermissionsModal (subscription/clinic_doctors stay owner-only by design). New App\Clinic\Security\ClinicDoctorAccessChecker (parallel to SecretaryAccessChecker): - denyUnlessGranted(user, resource, action): 403 only for a clinic-member doctor in the clinic context; owner/admin/secretary/independent-doctor pass through. - memberClinicId(user): resolves the member doctor to the CLINIC's tenant so the role-based controllers (Inventory/Tag/Staff/Discount/Sms) stop showing them their personal tenant in clinic context. Enforcement wired into 10 controllers alongside the existing secretary gates: ClinicService (services), Insurance (insurances), Patient (patients+payments), Staff, Discount, Inventory, Tag, SmsWallet, Payment, PaymentMethod. Frontend: the guest-doctor sidebar branch now exposes every permitted resource (gated by can()) plus a «تنظیمات» entry; both settings navs (PurchaseSubscription Sidebar + SETTINGS_MENU) are now permission-filtered for a scope=clinic doctor, not just secretaries; my-payments route gets the missing payments permission. CRUD-button gating already applies (usePermissions is role-agnostic). Tests: ClinicDoctorPermissionEnforcementTest (member denied/allowed + independent-doctor-unrestricted); guest-doctor sidebar gating. Backend 375 pass, frontend 503 pass. docs/api/clinic.md updated with the full resource set + enforcement notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ use App\ClinicService\Entity\ServiceItemAuditLog;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\ClinicService\Entity\Tariff;
|
||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||
@@ -51,8 +52,16 @@ class ClinicServiceController extends BaseController
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
/** گِیتِ ترکیبی: منشی + پزشکِ عضوِ کلینیک (هرکدام فقط نقشِ خودش را محدود میکند). */
|
||||
private function denyServices(User $user, string $action): void
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* uuid و عنوان پکیج کالای هر سرویس را به آرایهی خروجی اضافه میکند. پکیجها با یک
|
||||
* کوئری واکشی میشوند تا فهرست سرویسها به N+1 نیفتد.
|
||||
@@ -140,7 +149,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-sections', methods: ['GET'])]
|
||||
public function listSections(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -158,7 +167,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section', methods: ['POST'])]
|
||||
public function createSection(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create');
|
||||
$this->denyServices($user, 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -178,7 +187,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSection(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
$this->denyServices($user, 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -203,7 +212,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete');
|
||||
$this->denyServices($user, 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -223,7 +232,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-items', methods: ['GET'])]
|
||||
public function listAllItems(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
// A user with neither a doctor profile nor a clinic (admin, secretary,
|
||||
@@ -242,7 +251,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
|
||||
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($sectionUuid);
|
||||
@@ -256,7 +265,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['GET'])]
|
||||
public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -271,7 +280,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}/audit-logs', methods: ['GET'])]
|
||||
public function listItemAuditLogs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -288,7 +297,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item', methods: ['POST'])]
|
||||
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create');
|
||||
$this->denyServices($user, 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -343,7 +352,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])]
|
||||
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
$this->denyServices($user, 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -398,7 +407,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete');
|
||||
$this->denyServices($user, 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -429,7 +438,7 @@ class ClinicServiceController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function listTariffs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -450,7 +459,7 @@ class ClinicServiceController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function setTariff(string $uuid, int $year, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
$this->denyServices($user, 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
|
||||
Reference in New Issue
Block a user