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
@@ -59,6 +59,7 @@ class PatientController extends BaseController
private readonly \App\Patient\Repository\SessionPaymentRepository $sessionPaymentRepo,
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly LoggerInterface $logger,
) {}
@@ -144,6 +145,7 @@ class PatientController extends BaseController
public function chargeWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -180,6 +182,7 @@ class PatientController extends BaseController
public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -240,6 +243,7 @@ class PatientController extends BaseController
public function createCall(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -277,6 +281,7 @@ class PatientController extends BaseController
public function deleteCall(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$call = $this->callRepo->findByUuid($uuid);
if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId, $user)) {
@@ -309,6 +314,7 @@ class PatientController extends BaseController
public function createMessage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -335,6 +341,7 @@ class PatientController extends BaseController
public function deleteMessage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$message = $this->messageRepo->findByUuid($uuid);
if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId, $user)) {
@@ -370,6 +377,7 @@ class PatientController extends BaseController
public function createNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -393,6 +401,7 @@ class PatientController extends BaseController
public function updateNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
@@ -419,6 +428,7 @@ class PatientController extends BaseController
public function deleteNote(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
@@ -451,6 +461,7 @@ class PatientController extends BaseController
public function createMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -476,6 +487,7 @@ class PatientController extends BaseController
public function updateMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
@@ -507,6 +519,7 @@ class PatientController extends BaseController
public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
@@ -539,6 +552,7 @@ class PatientController extends BaseController
public function uploadAttachment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -562,6 +576,7 @@ class PatientController extends BaseController
public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$attachment = $this->attachmentRepo->findByUuid($uuid);
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId, $user)) {
@@ -700,6 +715,7 @@ class PatientController extends BaseController
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'create');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -794,6 +810,7 @@ class PatientController extends BaseController
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1004,6 +1021,7 @@ class PatientController extends BaseController
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1048,6 +1066,7 @@ class PatientController extends BaseController
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1124,6 +1143,7 @@ class PatientController extends BaseController
public function addSessionPayment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1150,6 +1170,7 @@ class PatientController extends BaseController
public function updateSessionPayment(string $uuid, string $paymentUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'update');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1172,6 +1193,7 @@ class PatientController extends BaseController
public function deleteSessionPayment(string $uuid, string $paymentUuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'delete');
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);