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:
@@ -46,6 +46,7 @@ class InsuranceController extends BaseController
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Patient\Security\PatientRecordScopeResolver $scopeResolver,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -255,6 +256,7 @@ class InsuranceController extends BaseController
|
||||
public function getInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
@@ -305,6 +307,7 @@ class InsuranceController extends BaseController
|
||||
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
@@ -375,6 +378,7 @@ class InsuranceController extends BaseController
|
||||
public function listTenantInsurances(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
@@ -406,6 +410,7 @@ class InsuranceController extends BaseController
|
||||
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'create');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
@@ -442,6 +447,7 @@ class InsuranceController extends BaseController
|
||||
public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
@@ -488,6 +494,7 @@ class InsuranceController extends BaseController
|
||||
public function deactivateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'delete');
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'update');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
@@ -508,6 +515,7 @@ class InsuranceController extends BaseController
|
||||
public function listServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
@@ -539,6 +547,7 @@ class InsuranceController extends BaseController
|
||||
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
@@ -585,6 +594,7 @@ class InsuranceController extends BaseController
|
||||
public function addDoctorInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'create');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorId = $data['doctor_id'] ?? null;
|
||||
$insuranceId = $data['insurance_id'] ?? null;
|
||||
@@ -626,6 +636,7 @@ class InsuranceController extends BaseController
|
||||
public function showDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
|
||||
if ($doctorInsurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
|
||||
@@ -643,6 +654,7 @@ class InsuranceController extends BaseController
|
||||
public function updateDoctorInsurance(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
|
||||
if ($doctorInsurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
|
||||
@@ -666,6 +678,7 @@ class InsuranceController extends BaseController
|
||||
public function deleteDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'delete');
|
||||
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
|
||||
if ($doctorInsurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
|
||||
|
||||
Reference in New Issue
Block a user