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:
hamed
2026-07-23 19:09:16 +03:30
co-authored by Claude Opus 4.8
parent 1116ac14cb
commit f33c7a3eab
21 changed files with 617 additions and 31 deletions
@@ -39,6 +39,7 @@ class InventoryController extends BaseController
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
// ── Items ────────────────────────────────────────────────────────────────
@@ -47,6 +48,7 @@ class InventoryController extends BaseController
public function listItems(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -64,6 +66,7 @@ class InventoryController extends BaseController
public function listCategories(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -85,6 +88,7 @@ class InventoryController extends BaseController
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'create');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'create');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -107,6 +111,7 @@ class InventoryController extends BaseController
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'update');
$item = $this->ownedItem($uuid, $user);
if ($item === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
@@ -130,6 +135,7 @@ class InventoryController extends BaseController
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'delete');
$item = $this->ownedItem($uuid, $user);
if ($item === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
@@ -146,6 +152,7 @@ class InventoryController extends BaseController
public function listPackages(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -161,6 +168,7 @@ class InventoryController extends BaseController
public function createPackage(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'create');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'create');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -183,6 +191,7 @@ class InventoryController extends BaseController
public function updatePackage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'update');
$package = $this->ownedPackage($uuid, $user);
if ($package === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
@@ -210,6 +219,7 @@ class InventoryController extends BaseController
public function deletePackage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'delete');
$package = $this->ownedPackage($uuid, $user);
if ($package === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
@@ -280,6 +290,10 @@ class InventoryController extends BaseController
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$memberClinicId = $this->clinicDoctorAccess->memberClinicId($user);
if ($memberClinicId !== null) {
return ['clinic', $memberClinicId];
}
$doctor = $this->doctorRepo->findByUser($user);
return ['doctor', $doctor?->getId()];
}