>({
- queryKey: ['clinic-doctors', dbUuid],
- queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
- enabled: isClinic && !!dbUuid,
+ queryKey: ['clinic-doctors', dbUuid, isClinicScopedSecretary],
+ // منشی از اندپوینتِ احرازشده میگیرد تا فقط پزشکانِ تخصیصیافتهاش بیایند؛
+ // کلینیک/ادمین از لیستِ کاملِ عمومیِ کلینیک.
+ queryFn: () => api.get(
+ isClinicScopedSecretary
+ ? '/api/v1/my/clinic-doctors'
+ : `/api/v1/clinic/doctor-list/${dbUuid}`,
+ ),
+ enabled: (isClinic || isClinicScopedSecretary) && !!dbUuid,
});
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
@@ -570,16 +580,16 @@ export default function AppointmentsPage() {
// نوع پروفایل: کلینیک چندپزشکه (تب دکترها + مدیریت چند پزشک) در برابر پزشک مستقل.
// نقش clinic/admin = چندپزشکه؛ نقش doctor (حتی مهمانِ کلینیک) = مستقل، فقط برنامهٔ خودش.
- const isMultiDoctorClinic = isClinic || isAdmin;
+ const isMultiDoctorClinic = isClinic || isAdmin || isClinicScopedSecretary;
const showDoctorTabs = isMultiDoctorClinic && doctors.length >= 1;
const showDoctorCol = isAdmin && !selectedDoctorUuid;
// در کلینیک، اولین دکتر بهصورت پیشفرض انتخاب میشود تا زمانبندی مثل طرح پر باشد.
useEffect(() => {
- if (isClinic && !selectedDoctorUuid && doctors.length > 0) {
+ if ((isClinic || isClinicScopedSecretary) && !selectedDoctorUuid && doctors.length > 0) {
setSelectedDoctorUuid(doctors[0].uuid);
}
- }, [isClinic, selectedDoctorUuid, doctors]);
+ }, [isClinic, isClinicScopedSecretary, selectedDoctorUuid, doctors]);
// ── محل نوبتدهی برای ادمین
// ادمین context کلینیکی ندارد (useClinicContext → null)؛ بدون clinic_uuid فقط برنامهٔ
diff --git a/assets/admin/pages/SettingsMenuPage.tsx b/assets/admin/pages/SettingsMenuPage.tsx
index 94de88f3..e543eb38 100644
--- a/assets/admin/pages/SettingsMenuPage.tsx
+++ b/assets/admin/pages/SettingsMenuPage.tsx
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { ChevronLeftIcon } from '@heroicons/react/24/outline';
import { menuForRole } from '../components/layout/SettingsLayout';
import { useAuthStore } from '../stores/authStore';
+import { usePermissions } from '../hooks/usePermissions';
/**
* SettingsMenuPage — the settings landing list for doctor/clinic users.
@@ -12,7 +13,8 @@ import { useAuthStore } from '../stores/authStore';
*/
export default function SettingsMenuPage() {
const primaryRole = useAuthStore((s) => s.primaryRole);
- const items = menuForRole(primaryRole);
+ const { can } = usePermissions();
+ const items = menuForRole(primaryRole, can);
return (
diff --git a/docs/api/appointment.md b/docs/api/appointment.md
index bf6cf2f6..d005283b 100644
--- a/docs/api/appointment.md
+++ b/docs/api/appointment.md
@@ -1045,3 +1045,26 @@ sessions exist, otherwise one of:
Clients must not translate an empty `sessions` array into "closed". The admin panel used to do
exactly that and reported «این روز تعطیل است» for a doctor whose clinic schedule was perfectly
active — the request simply carried no `clinic_uuid`.
+
+---
+
+## GET /api/v1/my/clinic-doctors
+
+**Permission:** `IS_AUTHENTICATED_FULLY`
+
+پزشکانِ در دسترسِ کاربرِ پنل، برای ساختِ تبها/تایملاینِ صفحهٔ نوبتها. برخلاف
+`GET /api/v1/clinic/doctor-list/{clinicUuid}` که روی firewallِ عمومی است و **همهٔ** پزشکانِ
+کلینیک را برمیگرداند، این اندپوینت احرازشده است و نتیجه را بر اساس نقش محدود میکند:
+
+- **منشیِ محیطِ کلینیک** → فقط پزشکانِ **تخصیصیافته** به همان منشی (`DoctorSecretary` فعال).
+- **منشیِ محیطِ مطب** → همان یک پزشک.
+- **کلینیک** → همهٔ پزشکانِ کلینیک · **پزشک** → خودش.
+
+بدونِ این، منشیِ کلینیک پزشکی را در تب میدید که برایش مجوزِ نوبت نداشت و روی
+slot/booking، `403` میگرفت.
+
+**Response `200`:**
+
+```json
+{ "success": true, "data": { "data": [ { "uuid": "…", "name": "دکتر …" } ] } }
+```
diff --git a/docs/api/secretary.md b/docs/api/secretary.md
index 41be72d1..052be039 100644
--- a/docs/api/secretary.md
+++ b/docs/api/secretary.md
@@ -137,7 +137,7 @@ Create a secretary for a doctor.
| Resource | Enforced in | Action → endpoint |
| --- | --- | --- |
| `appointments` | `AppointmentAccessChecker`, `MyAppointmentsController`, `DashboardController` | view/create/cancel/update_status |
-| `patients` | `PatientController` (خواندنها via `scope()` → بدون `view` هیچ پروندهای؛ نوشتنها با guard) | view/create/update/delete |
+| `patients` | `PatientController` (خواندنها via `scope()` → بدون `view` هیچ پروندهای؛ افزودن/ویرایشِ زیرآیتمها = `update`؛ **حذفِ** یادداشت/سند/رکورد/تماس/پیام = `delete` — جدا از `update`) | view/create/update/delete |
| `payments` | `PaymentController::myPayments`, `PaymentMethodController` (bank/pos), `PatientController` (کیفپول + پرداختِ جلسه) | view/create/update/delete |
| `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete |
| `inventory` | `InventoryController` (items + packages) | view/create/update/delete |
diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php
index a80637d2..d5e11319 100644
--- a/src/Appointment/Controller/MyAppointmentsController.php
+++ b/src/Appointment/Controller/MyAppointmentsController.php
@@ -47,6 +47,43 @@ class MyAppointmentsController extends BaseController
private readonly VisitPriceRequirementResolver $visitPriceResolver,
) {}
+ /**
+ * پزشکانِ در دسترسِ کاربرِ پنل — برای تبها/تایملاینِ نوبتها. برخلاف
+ * /clinic/doctor-list (که عمومی است و همهٔ پزشکانِ کلینیک را میدهد)، این
+ * اندپوینت احرازشده است: منشی فقط پزشکانِ تخصیصیافتهٔ خودش را میگیرد، پس تبها
+ * دقیقاً با مجوزِ نوبتدهیاش همراستا میشوند.
+ */
+ #[Route('/api/v1/my/clinic-doctors', methods: ['GET'])]
+ #[IsGranted('IS_AUTHENTICATED_FULLY')]
+ public function myClinicDoctors(#[CurrentUser] User $user): JsonResponse
+ {
+ $roles = $user->getRoles();
+ $doctors = [];
+
+ if (in_array('ROLE_SECRETARY', $roles, true)) {
+ $filter = $this->resolveSecretaryFilter($user);
+ if ($filter !== null) {
+ [$type, $value] = $filter;
+ $doctors = $type === 'clinic'
+ ? ($value === [] ? [] : $this->doctorRepo->findBy(['id' => $value]))
+ : [$value];
+ }
+ } elseif (in_array('ROLE_CLINIC', $roles, true)) {
+ $clinic = $this->clinicRepo->findByUser($user);
+ $doctors = $clinic !== null ? $clinic->getDoctors()->toArray() : [];
+ } elseif (in_array('ROLE_DOCTOR', $roles, true)) {
+ $doctor = $this->doctorRepo->findByUser($user);
+ $doctors = $doctor !== null ? [$doctor] : [];
+ }
+
+ $data = array_map(
+ static fn(Doctor $d) => ['uuid' => $d->getUuid(), 'name' => $d->getName()],
+ $doctors,
+ );
+
+ return $this->success(['data' => $data]);
+ }
+
#[Route('/api/v1/my/appointment', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function createAppointment(Request $request, #[CurrentUser] User $user): JsonResponse
diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php
index 75065f6a..d2e15c49 100644
--- a/src/Clinic/Controller/ClinicController.php
+++ b/src/Clinic/Controller/ClinicController.php
@@ -333,6 +333,9 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
+ // این اندپوینت روی firewallِ عمومی است (سایت هم بیتوکن مصرفش میکند) → اینجا
+ // کاربر احراز نمیشود. محدودسازیِ منشی به پزشکانِ تخصیصیافته در اندپوینتِ
+ // احرازشدهٔ پنل انجام میشود: GET /api/v1/my/clinic-doctors.
$result = $this->doctorRepo->findByClinicWithFilters((int) $clinic->getId(), $request->query->all());
$clinicDoctors = $result['items'];
diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php
index d79ebb1e..d31acf0e 100644
--- a/src/Patient/Controller/PatientController.php
+++ b/src/Patient/Controller/PatientController.php
@@ -276,7 +276,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/call/{uuid}', methods: ['DELETE'])]
public function deleteCall(string $uuid, #[CurrentUser] User $user): JsonResponse
{
- $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
+ $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$call = $this->callRepo->findByUuid($uuid);
if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId, $user)) {
@@ -334,7 +334,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/message/{uuid}', methods: ['DELETE'])]
public function deleteMessage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
- $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
+ $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$message = $this->messageRepo->findByUuid($uuid);
if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId, $user)) {
@@ -418,7 +418,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/note/{uuid}', methods: ['DELETE'])]
public function deleteNote(string $uuid, #[CurrentUser] User $user): JsonResponse
{
- $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
+ $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
@@ -506,7 +506,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/medical-record/{uuid}', methods: ['DELETE'])]
public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse
{
- $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
+ $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
@@ -561,7 +561,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/attachment/{uuid}', methods: ['DELETE'])]
public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse
{
- $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
+ $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$attachment = $this->attachmentRepo->findByUuid($uuid);
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId, $user)) {
diff --git a/src/Secretary/Security/SecretaryAccessChecker.php b/src/Secretary/Security/SecretaryAccessChecker.php
index 5f419f9f..3d46bfd0 100644
--- a/src/Secretary/Security/SecretaryAccessChecker.php
+++ b/src/Secretary/Security/SecretaryAccessChecker.php
@@ -126,6 +126,25 @@ class SecretaryAccessChecker
return $relation !== null && $this->permissions->can($relation, $resource, $action);
}
+ /**
+ * idهای پزشکانِ تخصیصیافته به این منشی در این کلینیک — برای محدودکردنِ
+ * لیستهایی که پیشفرض همهٔ پزشکانِ کلینیک را برمیگردانند. اگر منشی نیست یا
+ * محیطش این کلینیک نیست → آرایهٔ خالی.
+ *
+ * @return int[]
+ */
+ public function assignedClinicDoctorIds(User $user, \App\Clinic\Entity\Clinic $clinic): array
+ {
+ if (!$user->hasRole('ROLE_SECRETARY')) {
+ return [];
+ }
+
+ return array_map(
+ static fn(\App\Doctor\Entity\Doctor $d) => $d->getId(),
+ $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic),
+ );
+ }
+
/**
* آیا منشی در محیطِ فعالِ خود — که باید همین کلینیک باشد — مجاز به resource/action است؟
* برای منابعِ کلینیکسطح مثل clinic_doctors که tenant لزوماً کلینیک است.
diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php
index 137d0fc8..fb0d05ce 100644
--- a/tests/Secretary/SecretaryResourceEnforcementTest.php
+++ b/tests/Secretary/SecretaryResourceEnforcementTest.php
@@ -226,6 +226,57 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
$this->assertSame(200, $this->responseCode());
}
+ public function testPatientDeleteSeparateFromUpdate(): void
+ {
+ // منشی با patients.update ولی بدون patients.delete نباید بتواند حذف کند.
+ // گیتِ delete پیش از واکشیِ رکورد اجرا میشود، پس uuidِ ناموجود هم ۴۰۳ میدهد.
+ [$secretary, $rel] = $this->makeClinicSecretary();
+ $rel->mergePermissions(['resources' => ['patients' => ['view' => true, 'update' => true, 'delete' => false]]]);
+ $this->em->flush();
+
+ $this->authJson('DELETE', '/api/v1/patient/note/00000000-0000-0000-0000-000000000000', $secretary);
+ $this->assertSame(403, $this->responseCode(), 'حذف باید جدا از ویرایش کنترل شود');
+ }
+
+ public function testPatientDeleteAllowedWhenGranted(): void
+ {
+ // با patients.delete، گیت عبور میکند و به «یافت نشد» میرسد (نه ۴۰۳).
+ [$secretary, $rel] = $this->makeClinicSecretary();
+ $rel->mergePermissions(['resources' => ['patients' => ['view' => true, 'delete' => true]]]);
+ $this->em->flush();
+
+ $this->authJson('DELETE', '/api/v1/patient/note/00000000-0000-0000-0000-000000000000', $secretary);
+ $this->assertSame(404, $this->responseCode());
+ }
+
+ public function testDoctorListReturnsOnlyAssignedDoctors(): void
+ {
+ // کلینیک با دو پزشک؛ منشی فقط به یکی تخصیص داده شده.
+ $owner = $this->createUser(['ROLE_CLINIC']);
+ $clinic = new Clinic($owner);
+ $this->em->persist($clinic);
+
+ $assigned = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تخصیصیافته');
+ $unassigned = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر دیگر');
+ $this->em->persist($assigned);
+ $this->em->persist($unassigned);
+ $clinic->getDoctors()->add($assigned);
+ $clinic->getDoctors()->add($unassigned);
+
+ $secretary = $this->createUser(['ROLE_SECRETARY']);
+ $this->em->persist(new DoctorSecretary($assigned, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic));
+ $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid()));
+ $this->em->flush();
+
+ // اندپوینتِ احرازشدهٔ پنل (نه /clinic/doctor-list که عمومی است).
+ $body = $this->authJson('GET', '/api/v1/my/clinic-doctors', $secretary);
+ $this->assertSame(200, $this->responseCode());
+
+ $names = array_map(static fn($d) => $d['name'], $body['data']['data']);
+ $this->assertContains('دکتر تخصیصیافته', $names);
+ $this->assertNotContains('دکتر دیگر', $names, 'منشی نباید پزشکِ تخصیصنیافته را ببیند');
+ }
+
/** مثل makeClinicSecretary اما clinic و doctor را هم برمیگرداند. */
private function makeClinicSecretaryWithClinic(): array
{