feat(secretary): implement multi-doctor assignment for clinic secretaries

- Added functionality to assign a single secretary to multiple doctors within a clinic, allowing for scoped access to appointments.
- Introduced `SecretaryService` to handle the logic for assigning and syncing doctors for a secretary.
- Updated `SecretaryController` to support multi-doctor assignment via new endpoints and modified existing ones.
- Enhanced `DoctorSecretary` entity to include secretary UUID in its serialized output.
- Implemented repository methods to facilitate the retrieval and management of doctor-secretary relationships.
- Adjusted appointment filtering in `MyAppointmentsController` to ensure secretaries only see appointments for assigned doctors.
- Created tests to validate the new multi-doctor assignment functionality and appointment access restrictions.
- Updated frontend components to support multi-select for doctors in the secretary management UI.
This commit is contained in:
hamed
2026-07-18 08:49:04 +03:30
parent 6ab7ed38b8
commit 1779e0d6de
13 changed files with 947 additions and 51 deletions
@@ -0,0 +1,134 @@
# منشیِ مشترک کلینیک: تخصیص یک منشی به چند پزشک با دسترسی محدود
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React — همان ریپو).
## زمینه
در یک کلینیک که چند پزشک دارد، مدیر کلینیک می‌خواهد **یک منشی را به یک یا چند پزشکِ همان کلینیک** تخصیص دهد، به‌طوری‌که دسترسی آن منشی **فقط به پزشکانِ تعیین‌شده** محدود باشد (نه همه‌ی پزشکان کلینیک). این ارتباط باید many-to-many، و بعداً قابل افزودن/حذف بدون تغییر ساختاری باشد.
**مهم — schema از قبل آماده است:** موجودیت `DoctorSecretary` (`src/Secretary/Entity/DoctorSecretary.php`) یک join row است: `(doctor + secretary User + owner_type[doctor|clinic] + clinic? + permissions json)` با unique روی `(doctor_id, secretary_id, owner_type)`. یعنی یک منشیِ user همین حالا می‌تواند **چند ردیف** داشته باشد (یکی per پزشک). Repository هم متد `findDoctorsBySecretaryInClinic($user, $clinic)` را دارد که دقیقاً «پزشکانِ تخصیص‌یافته‌ی این منشی در کلینیک» را برمی‌گرداند. **هیچ migration/تغییر schema لازم نیست.**
## مشکل / هدف
سه شکاف وجود دارد که باید پر شود:
1. **نوشتن تک‌پزشکی:** هر مسیر نوشتن فقط یک پزشک می‌گیرد. `POST /api/v1/secretary` فقط یک `doctor_uuid` می‌پذیرد؛ برای تخصیص به N پزشک باید N بار صدا زد. هیچ سرویس/endpoint اتمیک برای چند پزشک یا برای «هم‌گام‌سازی مجموعه‌ی پزشکانِ یک منشی» وجود ندارد. اصلاً پوشه‌ی `src/Secretary/Service/` نیست (منطق داخل کنترلر).
2. **UI تک‌انتخابی:** فرم کلینیک در `MySecretariesPage.tsx` پزشک را تک‌انتخابی می‌گیرد؛ multi-select و ویرایش لیست پزشکانِ منشیِ موجود نیست.
3. **⚠️ عدم اعمال scope (هسته‌ی خواسته):** منشیِ کلینیک الان **همه‌ی پزشکان کلینیک** را می‌بیند. لیست نوبت با `d MEMBER OF c.doctors` فیلتر می‌شود و گیت رزرو فقط عضویت در کلینیک را چک می‌کند — نه پزشکانِ تخصیص‌یافته. فقط داشبورد درست scope می‌شود (`findDoctorsBySecretaryInClinic`). بدون رفع این، «دسترسی محدود به پزشکان تعیین‌شده» فقط ظاهری است.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Secretary/Entity/DoctorSecretary.php` | join row؛ `OWNER_CLINIC`، `DEFAULT_PERMISSIONS`، `mergePermissions()` |
| `src/Secretary/Controller/SecretaryController.php` | `create` (خط ۳۹، تک‌پزشکی)، `update`/`show`/`deactivate`، `listByClinic` (خط ۲۴۱)، `canManage` (خط ۲۶۲) |
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | `findDoctorsBySecretaryInClinic` (خط ۹۲)، `findByClinic` (خط ۱۲۶)، `findActiveBySecretaryForClinic` (خط ۷۶)، `countActiveByDoctor` (خط ۲۴) |
| `src/Appointment/Controller/MyAppointmentsController.php` | `resolveSecretaryFilter` (خط ۵۰۴)، اعمال فیلتر clinic (خط ۳۰۳)، `secretaryCanBookForDoctor` (خط ۴۷۳) |
| `src/Dashboard/Controller/DashboardController.php` | خط ۴۱۶–۴۳۱ — الگوی درستِ scope با `findDoctorsBySecretaryInClinic` (مرجع کپی) |
| `src/Patient/Controller/PatientController.php` | `resolveEntity()` خط ۱۱۹۸ — scope بیمار (clinic vs doctor) |
| `assets/admin/pages/MySecretariesPage.tsx` | فرم مدیریت منشی؛ شاخه‌ی clinic (خط ۶۰۴)، picker تک‌انتخابی (خط ۷۳۷)، create mutation (خط ۶۵۴) |
| `assets/admin/types/index.ts` | تایپ `Secretary`/`SecretaryPermissions` (خط ۳۲۶) — بدون آرایه‌ی پزشک |
| `docs/api/secretary.md` | مستندات endpointها |
## وضعیت فعلی
### create تک‌پزشکی — `SecretaryController.php:39`
```php
#[Route('/api/v1/secretary', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $currentUser): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? ''); // ← فقط یک پزشک
// ...
$ownerClinic = null;
if ($currentUser->hasRole('ROLE_CLINIC')) {
$ownerClinic = $this->clinicRepo->findByUser($currentUser);
if ($ownerClinic === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $ownerClinic)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
} // ...
// find-or-create secretary user، duplicate guard روی (doctor, secretary, ownerType)
$secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerType, $ownerClinic); // ← یک ردیف
// ...
}
```
### scope ناقص برای نوبت — `MyAppointmentsController.php:303` و `:504`
```php
// اعمال فیلتر منشیِ کلینیک — همه‌ی پزشکان کلینیک، نه تخصیص‌یافته‌ها:
if ($filterType === 'clinic') {
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
->andWhere('c = :clinic')->setParameter('clinic', $filterValue);
}
```
```php
private function resolveSecretaryFilter(User $user): ?array {
// ...
if ($clinic !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); // فقط «آیا در کلینیک هست»
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
return ['clinic', $clinic, $canView]; // ← مجموعه‌ی پزشکانِ مجاز را حمل نمی‌کند
}
// ...
}
```
### الگوی درست (داشبورد) — `DashboardController.php:~416`
```php
// scope کلینیکِ منشی را به پزشکانِ تخصیص‌یافته محدود می‌کند:
$doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic);
```
## وظایف
> هر وظیفه: پیاده‌سازی → تست (موفق/خطا/مرزی) → مستند → گزارش. یک وظیفه در هر مرحله.
### ۱. سرویس منشی + تخصیص چند‌پزشکیِ اتمیک (Backend)
- پوشه/کلاس جدید `src/Secretary/Service/SecretaryService.php` بساز و منطق چاق فعلیِ `create` را به آن منتقل کن (SOLID؛ کنترلر فقط HTTP).
- متد `assignToDoctors(User $currentUser, string $mobile, array $doctorUuids, array $meta): array` که برای **مدیر کلینیک**:
- کلینیکِ مالک را از `$currentUser` می‌گیرد؛ هر `doctorUuid` باید عضو همان کلینیک باشد (`isDoctorInClinic`) وگرنه ۴۰۳/۴۲۲.
- منشیِ user را find-or-create می‌کند (مثل کد فعلی: نقش `ROLE_SECRETARY`، نام، پسورد اختیاری).
- برای هر پزشک یک `DoctorSecretary(doctor, user, OWNER_CLINIC, clinic)` می‌سازد؛ ردیف تکراری `(doctor, secretary, ownerType)` را **skip** کند (نه خطا).
- permissions ورودی را روی همه‌ی ردیف‌های ساخته‌شده اعمال کند (`mergePermissions`).
- همه در یک تراکنش؛ خروجی: لیست ردیف‌های نهایی + پزشکانِ skip‌شده.
- endpointها:
- `POST /api/v1/secretary` را طوری توسعه بده که **علاوه بر** `doctor_uuid` (سازگاری قدیمی)، آرایه‌ی `doctor_uuids: string[]` را هم بپذیرد؛ اگر آرایه آمد و کاربر `ROLE_CLINIC` است → `assignToDoctors`. رفتار تک‌پزشکیِ فعلی نشکند.
- `PUT /api/v1/secretaries/clinic/{clinicUuid}/secretary/{secretaryUuid}/doctors` (یا مسیر مشابه) برای **هم‌گام‌سازی**: بدنه `doctor_uuids: string[]`؛ ردیف‌های owner=clinicِ این منشی در این کلینیک را با مجموعه‌ی جدید sync می‌کند (افزودن نبودها، حذف/غیرفعال‌سازیِ اضافه‌ها). گارد: مالک کلینیک یا ادمین (`canManage`).
- **سقف پلن:** توجه کن `countActiveByDoctor` per-doctor است؛ تخصیص یک منشی به N پزشک روی سقفِ هر پزشک حساب می‌شود. همین منطق per-doctor را برای هر پزشک در حلقه چک کن (اگر پزشکی به سقف رسید، همان پزشک را skip و در خروجی گزارش کن، بقیه ادامه یابند).
- مستند: `docs/api/secretary.md` — بدنه‌ی جدید، مسیر sync، خطاها، مثال JSON واقعی.
- تست PHPUnit (`tests/Secretary/...`): تخصیص چند‌پزشکی موفق، skipِ تکراری، ۴۰۳ برای پزشکِ خارج از کلینیک، sync (افزودن+حذف)، غیرمالک ۴۰۳.
### ۲. اعمال scope دسترسی به پزشکانِ تخصیص‌یافته (Backend) — هسته
- `resolveSecretaryFilter` (`MyAppointmentsController.php:504`) در شاخه‌ی clinic: به‌جای بازگرداندن فقط Clinic، **مجموعه‌ی پزشکانِ تخصیص‌یافته** را با `findDoctorsBySecretaryInClinic($user, $clinic)` بگیر و در خروجی حمل کن (مثلاً `['clinic', $clinic, $canView, $doctorIds]`).
- اعمال فیلتر (`:303`): به‌جای `d MEMBER OF c.doctors` برای همه‌ی کلینیک، `a.doctor IN (:doctorIds)` با آن مجموعه. اگر مجموعه خالی بود → صفحه‌ی خالی.
- `secretaryCanBookForDoctor` (`:473`) در شاخه‌ی clinic: علاوه بر عضویت در کلینیک و permission، چک کن `$doctor` در `findDoctorsBySecretaryInClinic` باشد.
- مسیرهای مشابه را هم هم‌سو کن: `PatientController::resolveEntity` (`:1198`) و Billing clinic-scope (اگر منشیِ کلینیک بیمار/مالی می‌بیند) باید به همان مجموعه‌ی پزشکان محدود شوند. الگو را از `DashboardController.php:~416` (که درست است) کپی کن — منطق مشترک را در سرویس/متد کمکی بگذار، نه کپیِ پراکنده.
- تست: منشیِ تخصیص‌یافته به پزشک A (نه B) در کلینیکِ دارای A و B → لیست نوبت فقط A؛ رزرو برای B ممنوع؛ داشبورد و لیست هم‌خوان.
### ۳. Frontend — انتخاب چند پزشک و ویرایش تخصیص
- در `MySecretariesPage.tsx` شاخه‌ی `isClinic`:
- picker پزشک را از تک‌انتخابی به **multi-select** تبدیل کن (چک‌لیستِ پزشکانِ کلینیک؛ از الگوی `MultiCheckList`/`SearchableSelect` موجود استفاده کن — طبق قانون پروژه از `SearchableSelect` استفاده شود، نه `<select>` بومی).
- create mutation به‌جای `doctor_uuid` تکی، `doctor_uuids: string[]` بفرستد.
- برای منشیِ موجود، امکان ویرایش مجموعه‌ی پزشکان (فراخوانی endpoint sync وظیفه‌ی ۱). نمایش پزشکانِ فعلیِ هر منشی (از `listByClinic` که ردیف‌ها را per پزشک می‌دهد — گروه‌بندی بر اساس منشی/موبایل).
- نمایش پیام برای پزشکانِ skip‌شده به‌خاطر سقف پلن.
- تایپ `Secretary` در `types/index.ts`: افزودن `doctors?: { uuid: string; name: string }[]` یا `doctor_uuids`.
- تست vitest: رندر multi-select، ارسال آرایه در mutation، گروه‌بندی منشی با چند پزشک. (تست‌ها روی host اجرا شوند: `npx vitest --run <file>` — node_modules داخل ddev برای esbuild لینوکسی نیست.)
## نکات مهم
- **بدون تغییر schema.** فقط ردیف‌های `DoctorSecretary` اضافه/حذف می‌شوند. اگر لازم شد ردیف حذف شود، هماهنگ با رفتار فعلی `deactivate` (soft `setActive(false)`) تصمیم بگیر — برای sync، حذف واقعی یا غیرفعال‌سازی را یکدست انتخاب کن و مستند کن.
- **سازگاری قدیمی:** مسیر تک‌پزشکیِ `doctor_uuid` و جریان منشیِ owner=doctor نباید بشکند.
- **envelope:** پاسخ‌ها با `$this->success(...)`/`$this->paginated(...)`؛ لیست‌های admin با array hydration. سمت فرانت single = `data?.data` (ممکن double-nested)، paginated items = `data?.data`.
- **context منشی:** ورود منشی یک context per کلینیک می‌سازد (`AuthController.php:~737`)؛ scope در هر request از `UserActiveContext` خوانده می‌شود. تغییرات وظیفه‌ی ۲ در همان لایه‌ی resolve اعمال شود، نه در ساخت context.
- **permissionها:** ماتریس دسترسی منشی (`DEFAULT_PERMISSIONS`) دست‌نخورده؛ scopeِ پزشک یک لایه‌ی مستقل و مقدم بر permission است.
- **RTL/فارسی، تاریخ‌ها Unix + شمسی.**
- بعد از اتمام: `graphify update .` (طبق قانون؛ اول commit).
+143 -37
View File
@@ -5,7 +5,6 @@ import { toast } from "sonner";
import SettingsLayout from "../components/layout/SettingsLayout";
import ConfirmDialog from "../components/ui/ConfirmDialog";
import Modal from "../components/ui/Modal";
import SearchableSelect from "../components/ui/SearchableSelect";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatDate } from "../lib/utils";
@@ -284,6 +283,8 @@ function SecretaryModal({
mode,
data,
saving,
isClinic,
clinicDoctors,
onClose,
onSubmit,
}: {
@@ -291,8 +292,10 @@ function SecretaryModal({
mode: ModalMode;
data: Secretary | null;
saving: boolean;
isClinic: boolean;
clinicDoctors: ClinicDoctor[];
onClose: () => void;
onSubmit: (form: FormState) => void;
onSubmit: (form: FormState, doctorUuids: string[]) => void;
}) {
const [form, setForm] = useState<FormState>({
name: "",
@@ -302,9 +305,14 @@ function SecretaryModal({
address: "",
permission: EMPTY_PERMISSIONS,
});
const [doctorUuids, setDoctorUuids] = useState<string[]>([]);
// نمایش انتخاب چند پزشک فقط هنگام افزودنِ منشیِ کلینیک
const showDoctorPicker = isClinic && mode === "add";
useEffect(() => {
if (!open) return;
setDoctorUuids([]);
if ((mode === "edit" || mode === "view") && data) {
const parts = (data.user_name ?? "").split(" ");
setForm({
@@ -357,7 +365,9 @@ function SecretaryModal({
if (!form.telephone.trim()) return toast.error("لطفاً شماره تلفن را وارد کنید");
if (!/^09\d{9}$/.test(form.telephone))
return toast.error("شماره تلفن باید 11 رقم و با 09 شروع شود");
onSubmit(form);
if (showDoctorPicker && doctorUuids.length === 0)
return toast.error("حداقل یک پزشک را انتخاب کنید");
onSubmit(form, doctorUuids);
};
return (
@@ -388,6 +398,22 @@ function SecretaryModal({
}
>
<div dir="rtl" className="flex flex-col justify-start items-start gap-[24px] w-full">
{/* انتخاب پزشکان (فقط افزودن منشیِ کلینیک) */}
{showDoctorPicker && (
<div className="w-full">
<div className="flex items-center justify-between mb-[16px]">
<p className="text-[#525252] dark:text-[#D7D8ED] text-[16px] font-bold">
پزشکانِ این منشی
</p>
<span className="text-[13px] text-[#7E7E7E]">{doctorUuids.length} انتخابشده</span>
</div>
<p className="text-[13px] text-[#7E7E7E] mb-[12px]">
منشی فقط به نوبتها و اطلاعاتِ پزشکانِ انتخابشده دسترسی خواهد داشت.
</p>
<DoctorMultiSelect doctors={clinicDoctors} selected={doctorUuids} onChange={setDoctorUuids} />
</div>
)}
{/* اطلاعات پایه */}
<div className="w-full">
<p className="text-[#525252] dark:text-[#D7D8ED] text-[16px] font-bold mb-[16px]">
@@ -599,6 +625,97 @@ interface ClinicDoctor {
name: string;
}
// چک‌لیستِ چند-انتخابی پزشکان کلینیک برای تخصیص یک منشیِ مشترک
function DoctorMultiSelect({
doctors,
selected,
onChange,
}: {
doctors: ClinicDoctor[];
selected: string[];
onChange: (v: string[]) => void;
}) {
const [q, setQ] = useState("");
const filtered = q ? doctors.filter((d) => d.name.includes(q)) : doctors;
const toggle = (uuid: string) =>
onChange(selected.includes(uuid) ? selected.filter((x) => x !== uuid) : [...selected, uuid]);
const allSelected = doctors.length > 0 && selected.length === doctors.length;
const toggleAll = () => onChange(allSelected ? [] : doctors.map((d) => d.uuid));
return (
<div className="w-full border border-[#EFEFEF] dark:border-[#343645] rounded-[8px] overflow-hidden">
{/* هدر: جستجو + انتخاب همه */}
<div className="flex items-center gap-[8px] p-[10px] border-b border-[#EFEFEF] dark:border-[#343645] bg-[#FAFAFC] dark:bg-[#222433]">
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" className="flex-shrink-0">
<path d="M9 16A7 7 0 109 2a7 7 0 000 14zM18 18l-3.5-3.5" stroke="#9A9AB0" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="جستجوی پزشک..."
className="flex-1 text-[13px] bg-transparent outline-none text-[#525252] dark:text-[#D7D8ED]"
/>
<button
type="button"
onClick={toggleAll}
className="text-[12px] text-[#5559CE] font-medium whitespace-nowrap cursor-pointer"
>
{allSelected ? "لغو همه" : "انتخاب همه"}
</button>
</div>
{/* چیپ‌های انتخاب‌شده */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-[6px] p-[10px] border-b border-[#EFEFEF] dark:border-[#343645]">
{selected.map((uuid) => {
const d = doctors.find((x) => x.uuid === uuid);
if (!d) return null;
return (
<span
key={uuid}
onClick={() => toggle(uuid)}
className="inline-flex items-center gap-[4px] bg-[#EEF0FF] dark:bg-[#33365A] text-[#5559CE] dark:text-[#C7CEF4] text-[12px] px-[8px] py-[3px] rounded-full cursor-pointer"
>
{d.name}
<span className="text-[14px] leading-none">×</span>
</span>
);
})}
</div>
)}
{/* لیست پزشکان */}
<div className="max-h-[220px] overflow-y-auto">
{filtered.length === 0 ? (
<p className="text-center text-[12px] text-[#7E7E7E] py-[14px]">نتیجهای یافت نشد</p>
) : (
filtered.map((d) => {
const checked = selected.includes(d.uuid);
return (
<label
key={d.uuid}
className={
"flex items-center gap-[10px] px-[12px] py-[9px] cursor-pointer border-b border-[#F2F2F6] dark:border-[#2A2C3A] last:border-b-0 " +
(checked ? "bg-[#F5F6FF] dark:bg-[#2A2D45]" : "hover:bg-[#FAFAFC] dark:hover:bg-[#2A2C3A]")
}
>
<input
type="checkbox"
className="accent-[#5559CE] w-[16px] h-[16px]"
checked={checked}
onChange={() => toggle(d.uuid)}
/>
<Avatar name={d.name} size={26} />
<span className="text-[13px] text-[#525252] dark:text-[#D7D8ED]">{d.name}</span>
</label>
);
})
)}
</div>
</div>
);
}
function MySecretariesPageContent() {
const qc = useQueryClient();
const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
@@ -606,8 +723,7 @@ function MySecretariesPageContent() {
const { maxSecretaries } = useSubscription();
const [tab, setTab] = useState(0); // 0: منشی های فعلی، 1: منشی های قبلی
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>("");
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? "");
const activeDoctorUuid = doctorUuid ?? "";
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<ModalMode>("add");
@@ -615,7 +731,7 @@ function MySecretariesPageContent() {
const [deactivateTarget, setDeactivateTarget] = useState<Secretary | null>(null);
// clinic: list of doctors
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery<
const { data: clinicDoctorsData } = useQuery<
ApiResponse<{ data: ClinicDoctor[] }>
>({
queryKey: ["clinic-doctors", dbUuid],
@@ -652,17 +768,28 @@ function MySecretariesPageContent() {
};
const createMutation = useMutation({
mutationFn: (form: FormState) =>
api.post("/api/v1/secretary", {
doctor_uuid: activeDoctorUuid,
mutationFn: ({ form, doctorUuids }: { form: FormState; doctorUuids: string[] }) => {
const base = {
mobile_number: form.telephone,
name: `${form.name} ${form.family}`.trim(),
national_code: form.national_code || null,
address: form.address || null,
permissions: { version: 1, resources: form.permission },
}),
onSuccess: () => {
toast.success("منشی با موفقیت اضافه شد");
};
return api.post<ApiResponse<any>>(
"/api/v1/secretary",
isClinic
? { ...base, doctor_uuids: doctorUuids }
: { ...base, doctor_uuid: activeDoctorUuid },
);
},
onSuccess: (res: ApiResponse<any>) => {
const skippedLimit = res?.data?.skipped_limit?.length ?? 0;
if (isClinic && skippedLimit > 0) {
toast.warning(`${skippedLimit} پزشک به‌دلیل محدودیت پلن اضافه نشد`);
} else {
toast.success("منشی با موفقیت اضافه شد");
}
setModalOpen(false);
invalidate();
},
@@ -699,7 +826,6 @@ function MySecretariesPageContent() {
const saving = createMutation.isPending || updateMutation.isPending;
const handleAddClick = () => {
if (isClinic && !selectedDoctorUuid) return toast.error("ابتدا یک پزشک را انتخاب کنید");
if (atLimit) return toast.error(`حداکثر ${maxSecretaries} منشی مجاز است؛ برای افزودن، پنل را ارتقا دهید`);
setModalMode("add");
setSelected(null);
@@ -712,8 +838,8 @@ function MySecretariesPageContent() {
setModalOpen(true);
};
const handleModalSubmit = (form: FormState) => {
if (modalMode === "add") createMutation.mutate(form);
const handleModalSubmit = (form: FormState, doctorUuids: string[]) => {
if (modalMode === "add") createMutation.mutate({ form, doctorUuids });
else if (modalMode === "edit" && selected) updateMutation.mutate({ uuid: selected.uuid, form });
};
@@ -723,27 +849,6 @@ function MySecretariesPageContent() {
<p className="text-[#525252] dark:text-[#D7D8ED] text-[20px] font-bold">لیست منشی ها</p>
</div>
{/* کلینیک: انتخاب پزشک */}
{isClinic && (
<div className="mt-[16px] max-w-[360px]">
<label className="text-[#525252] dark:text-[#D7D8ED] text-[14px] font-medium block mb-[8px]">
پزشک مورد نظر برای افزودن منشی جدید
</label>
{clinicDoctorsLoading ? (
<p className="text-[13px] text-[#7E7E7E]">در حال بارگذاری...</p>
) : clinicDoctors.length === 0 ? (
<p className="text-[13px] text-[#7E7E7E]">هیچ پزشکی در این کلینیک تعریف نشده است.</p>
) : (
<SearchableSelect
options={clinicDoctors.map((d) => ({ value: d.uuid, label: d.name }))}
value={selectedDoctorUuid}
onChange={(v) => setSelectedDoctorUuid(v ? String(v) : "")}
placeholder="یک پزشک را انتخاب کنید"
/>
)}
</div>
)}
{/* تب‌ها + دکمه افزودن */}
<div className="w-full flex items-end justify-between mt-[20px]">
<div className="flex items-center gap-[8px] border-b border-[#EFEFEF] dark:border-[#343645]">
@@ -764,7 +869,6 @@ function MySecretariesPageContent() {
</div>
<button
onClick={handleAddClick}
disabled={isClinic && !selectedDoctorUuid}
className="shadow-none gap-[8px] bg-[#5559CE] text-[#EFEFEF] text-[14px] md:text-[15px] lg:text-[16px]
font-medium py-[10px] px-[16px] h-[43px] md:h-[45px] lg:h-[48px] rounded-[4px] cursor-pointer
flex items-center disabled:opacity-60"
@@ -813,6 +917,8 @@ function MySecretariesPageContent() {
mode={modalMode}
data={selected}
saving={saving}
isClinic={isClinic}
clinicDoctors={clinicDoctors}
onClose={() => setModalOpen(false)}
onSubmit={handleModalSubmit}
/>
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { screen, fireEvent } from "@testing-library/react";
import { renderWithProviders } from "../test/utils";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }));
vi.mock("../lib/api", () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock("../stores/authStore", () => ({
useAuthStore: () => ({ doctorUuid: null, dbUuid: "clinic-1", primaryRole: "clinic" }),
}));
vi.mock("../hooks/useSubscription", () => ({
useSubscription: () => ({ maxSecretaries: 5 }),
}));
import { api } from "../lib/api";
import MySecretariesPage from "./MySecretariesPage";
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
get.mockImplementation((url: string) => {
if (url.includes("/clinic/doctor-list/"))
return Promise.resolve({ success: true, data: { data: [
{ uuid: "doc-a", name: "دکتر الف" },
{ uuid: "doc-b", name: "دکتر ب" },
] } });
if (url.includes("/secretaries/clinic/")) return Promise.resolve({ success: true, data: [] });
return Promise.resolve({ success: true, data: [] });
});
});
describe("MySecretariesPage — clinic multi-doctor", () => {
it("shows the doctor multi-select inside the add modal", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
// پیکر روی صفحه نیست تا وقتی مودال باز شود
expect(screen.queryByText("پزشکانِ این منشی")).not.toBeInTheDocument();
fireEvent.click(await screen.findByText("اضافه کردن منشی"));
expect(await screen.findByText("پزشکانِ این منشی")).toBeInTheDocument();
expect(screen.getByText("دکتر الف")).toBeInTheDocument();
expect(screen.getByText("دکتر ب")).toBeInTheDocument();
expect(screen.getByText("انتخاب همه")).toBeInTheDocument();
});
it("selecting all picks every clinic doctor", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
fireEvent.click(await screen.findByText("اضافه کردن منشی"));
await screen.findByText("پزشکانِ این منشی");
fireEvent.click(screen.getByText("انتخاب همه"));
expect(screen.getByText(/[۲2] انتخاب‌شده/)).toBeInTheDocument();
expect(screen.getByText("لغو همه")).toBeInTheDocument();
});
});
+1
View File
@@ -383,6 +383,7 @@ export interface Blog {
export interface Secretary {
uuid: string;
secretary_uuid?: string;
user_name: string;
mobile_number: string;
doctor_name: string;
+4
View File
@@ -133,6 +133,10 @@ services:
arguments:
$appUrl: '%env(APP_BASE_URL)%'
App\Secretary\Service\SecretaryService:
arguments:
$appUrl: '%env(APP_BASE_URL)%'
App\Auth\Controller\PreRegistrationController:
arguments:
$appUrl: '%env(APP_BASE_URL)%'
+81 -2
View File
@@ -14,6 +14,7 @@
- یک منشی می‌تواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا)
- منشی کلینیک می‌تواند به چند دکتر در همان کلینیک متصل باشد
- scope فعال در runtime از جدول `user_active_context` (db_uuid) خوانده می‌شود
- **محدودسازی به پزشکانِ تخصیص‌یافته:** منشیِ کلینیک فقط نوبت‌های پزشکانی را می‌بیند/رزرو می‌کند که واقعاً به او تخصیص داده شده‌اند — نه همه‌ی پزشکان کلینیک. لیست نوبت (`GET /api/v1/my/appointments`) با `a.doctor IN (پزشکانِ تخصیص‌یافته)` فیلتر می‌شود و گیت رزرو (`POST /api/v1/my/appointment`) رابطه‌ی فعالِ همان (منشی، کلینیک، پزشک) را چک می‌کند. permission رزرو از همان ردیفِ پزشک خوانده می‌شود
Secretaries are linked to a doctor and have granular permissions controlling what they can do on behalf of the doctor.
@@ -76,7 +77,8 @@ Create a secretary for a doctor.
| Field | Type | Required | Description |
| --------------- | ------------- | -------- | -------------------------------------------- |
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
| `doctor_uuid` | string (UUID) | ✅\* | Single doctor to assign (legacy/doctor flow) |
| `doctor_uuids` | string[] (UUID) | ✅\* | **Clinic only** — assign one secretary to several clinic doctors at once. When present (non-empty) and caller is `ROLE_CLINIC`, this multi-doctor path is used instead of `doctor_uuid` |
| `mobile_number` | string | ✅ | Secretary's login mobile |
| `name` | string | ❌ | Full name (نام + نام خانوادگی) → `user_name` |
| `national_code` | string | ❌ | کد ملی منشی (nullable) |
@@ -84,6 +86,25 @@ Create a secretary for a doctor.
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
| `permissions` | object | ❌ | Permission set (see structure below) |
\* یکی از `doctor_uuid` (تک‌پزشکی) یا `doctor_uuids` (چند‌پزشکیِ کلینیک) الزامی است.
**پاسخِ حالت چند‌پزشکی (`doctor_uuids` + `ROLE_CLINIC`) — `201`:**
```json
{
"success": true,
"data": {
"secretary_uuid": "550e8400-...",
"created": [ { "uuid": "...", "secretary_uuid": "...", "doctor_uuid": "...", "...": "..." } ],
"skipped_duplicate": [],
"skipped_limit": [],
"skipped_not_in_clinic": []
}
}
```
- `created`: ردیف‌های تازه‌ساخته/فعال‌شده · `skipped_duplicate`: قبلاً متصل بوده · `skipped_limit`: سقفِ پلنِ آن پزشک پر است · `skipped_not_in_clinic`: پزشک عضو کلینیک نیست. حلقه اتمیک است و بقیه‌ی پزشکان ادامه می‌یابند.
**Permissions Structure:**
مجموعهٔ منابع (resources) بر اساس صفحات موجود پنل ادمین است. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند؛ فقط `appointments` در بک‌اند enforce می‌شود (`MyAppointmentsController`, `DashboardController`)، بقیه UI/ذخیره‌ای هستند.
@@ -344,6 +365,7 @@ Get all secretaries across **all doctors** of a clinic.
"data": [
{
"uuid": "...",
"secretary_uuid": "...",
"user_name": "علی محمدی",
"mobile_number": "09...",
"doctor_name": "دکتر احمد رضایی",
@@ -360,7 +382,7 @@ Get all secretaries across **all doctors** of a clinic.
- این endpoint فقط منشی های را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند
- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
- یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر)
- یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر). برای گروه‌بندی «یک منشی، چند پزشک» از `secretary_uuid` (uuid کاربرِ منشی) استفاده کنید
### Errors
@@ -372,6 +394,63 @@ Get all secretaries across **all doctors** of a clinic.
---
## PUT `/api/v1/secretaries/clinic/{clinicUuid}/doctors`
هم‌گام‌سازی مجموعه‌ی پزشکانِ یک منشیِ کلینیک (owner_type='clinic'): پزشکانِ خواسته‌شده افزوده/فعال و بقیه غیرفعال می‌شوند. برای «افزودن/حذف پزشک از یک منشی موجود» بدون تغییر ساختاری.
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
| ------------ | ------------- | ----------- |
| `clinicUuid` | string (UUID) | Clinic UUID |
### Request Body (`application/json`)
```json
{
"secretary_uuid": "550e8400-...",
"doctor_uuids": ["uuid-doc-a", "uuid-doc-b"]
}
```
| Field | Type | Required | Description |
| ---------------- | --------------- | -------- | ------------------------------------------------------------ |
| `secretary_uuid` | string (UUID) | ✅ | uuid کاربرِ منشی (همان `secretary_uuid` خروجی لیست/ساخت) |
| `doctor_uuids` | string[] (UUID) | ✅ | مجموعه‌ی نهاییِ پزشکان؛ نبودها افزوده، اضافه‌ها غیرفعال می‌شوند |
### Response `200`
```json
{
"success": true,
"data": {
"added": 1,
"removed": 1,
"skipped_limit": [],
"skipped_not_in_clinic": []
}
}
```
| Field | Type | Description |
| ----------------------- | -------- | ------------------------------------------------ |
| `added` | int | تعداد ردیف‌های افزوده/فعال‌شده |
| `removed` | int | تعداد ردیف‌های غیرفعال‌شده |
| `skipped_limit` | string[] | uuid پزشکانی که به سقفِ پلن رسیده‌اند (نادیده گرفته) |
| `skipped_not_in_clinic` | string[] | uuid پزشکانی که عضو این کلینیک نیستند |
### Errors
| Code | HTTP | Description |
| -------------------- | ---- | ------------------------------------ |
| `ERR_AUTH_006` | 403 | Not clinic owner nor admin |
| `ERR_VALIDATION_001` | 422 | `secretary_uuid`/`doctor_uuids` missing |
| `ERR_VALIDATION_002` | 404 | Clinic or secretary not found |
---
## محدودیت پنل اشتراکی
تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود:
@@ -301,9 +301,12 @@ class MyAppointmentsController extends BaseController
return $this->paginated([], 0, $page, $limit);
}
if ($filterType === 'clinic') {
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
->andWhere('c = :clinic')
->setParameter('clinic', $filterValue);
// $filterValue = آرایه‌ی idهای پزشکانِ تخصیص‌یافته به این منشی در کلینیک
if (empty($filterValue)) {
return $this->paginated([], 0, $page, $limit);
}
$qb->andWhere('a.doctor IN (:doctorIds)')
->setParameter('doctorIds', $filterValue);
} else {
$qb->andWhere('a.doctor = :doctor')
->setParameter('doctor', $filterValue);
@@ -479,10 +482,8 @@ class MyAppointmentsController extends BaseController
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if (!$clinic->getDoctors()->contains($doctor)) {
return false;
}
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
// منشی فقط برای پزشکانِ تخصیص‌یافته‌ی خودش می‌تواند رزرو کند، نه کل کلینیک
$rel = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $doctor);
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
}
@@ -510,13 +511,17 @@ class MyAppointmentsController extends BaseController
return null;
}
// بررسی scope کلینیک
// بررسی scope کلینیک — فقط پزشکانِ تخصیص‌یافته به این منشی، نه کل کلینیک
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
return ['clinic', $clinic, $canView];
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
$doctorIds = array_map(
fn(Doctor $d) => $d->getId(),
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic)
);
return ['clinic', $doctorIds, $canView];
}
// بررسی scope مطب شخصی
@@ -8,6 +8,7 @@ use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Secretary\Service\SecretaryService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Entity\SmsLog;
@@ -33,6 +34,7 @@ class SecretaryController extends BaseController
private readonly UserPasswordHasherInterface $hasher,
private readonly SubscriptionService $subscriptionService,
private readonly SmsService $smsService,
private readonly SecretaryService $secretaryService,
private readonly string $appUrl,
) {}
@@ -43,6 +45,12 @@ class SecretaryController extends BaseController
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$mobile = trim($data['mobile_number'] ?? '');
// تخصیص چند‌پزشکی توسط مدیر کلینیک
$doctorUuids = $data['doctor_uuids'] ?? null;
if (is_array($doctorUuids) && $doctorUuids !== []) {
return $this->createForClinicDoctors($currentUser, $mobile, $doctorUuids, $data);
}
if (empty($doctorUuid) || empty($mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid و mobile_number الزامی است', 422);
}
@@ -258,6 +266,71 @@ class SecretaryController extends BaseController
return $this->success($secretaries);
}
/** تخصیص یک منشی به چند پزشکِ کلینیک (فقط مدیر کلینیک) */
private function createForClinicDoctors(User $currentUser, string $mobile, array $doctorUuids, array $data): JsonResponse
{
if (empty($mobile)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'mobile_number الزامی است', 422);
}
if (!$currentUser->hasRole('ROLE_CLINIC')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$clinic = $this->clinicRepo->findByUser($currentUser);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$result = $this->secretaryService->assignToClinicDoctors($clinic, $mobile, $doctorUuids, [
'name' => $data['name'] ?? null,
'password' => $data['password'] ?? null,
'national_code' => $data['national_code'] ?? null,
'address' => $data['address'] ?? null,
'permissions' => $data['permissions'] ?? null,
]);
return $this->success([
'secretary_uuid' => $result['secretary']->getUuid(),
'created' => array_map(fn(DoctorSecretary $s) => $s->toArray(), $result['created']),
'skipped_duplicate' => $result['skipped_duplicate'],
'skipped_limit' => $result['skipped_limit'],
'skipped_not_in_clinic' => $result['skipped_not_in_clinic'],
], 201);
}
/** هم‌گام‌سازی مجموعه‌ی پزشکانِ یک منشیِ کلینیک */
#[Route('/api/v1/secretaries/clinic/{clinicUuid}/doctors', methods: ['PUT'])]
public function syncClinicDoctors(string $clinicUuid, Request $request, #[CurrentUser] User $currentUser): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if ($clinic->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$secretaryUuid = trim($data['secretary_uuid'] ?? '');
$doctorUuids = $data['doctor_uuids'] ?? null;
if (empty($secretaryUuid) || !is_array($doctorUuids)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'secretary_uuid و doctor_uuids الزامی است', 422);
}
$secretaryUser = $this->userRepo->findByUuid($secretaryUuid);
if ($secretaryUser === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404);
}
$result = $this->secretaryService->syncClinicDoctors($clinic, $secretaryUser, $doctorUuids);
return $this->success([
'added' => count($result['added']),
'removed' => count($result['removed']),
'skipped_limit' => $result['skipped_limit'],
'skipped_not_in_clinic' => $result['skipped_not_in_clinic'],
]);
}
/** بررسی دسترسی برای ویرایش/حذف یک رابطه منشی — scope-aware */
private function canManage(DoctorSecretary $secretary, User $user): bool
{
+1
View File
@@ -127,6 +127,7 @@ class DoctorSecretary
{
return [
'uuid' => $this->uuid,
'secretary_uuid' => $this->secretary->getUuid(),
'user_name' => $this->secretary->getRealName(),
'mobile_number' => $this->secretary->getMobileNumber(),
'doctor_name' => $this->doctor->getName(),
@@ -88,12 +88,25 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
->getOneOrNullResult();
}
/** رابطه فعالِ owner=clinic برای یک (منشی، کلینیک، پزشک) مشخص */
public function findActiveClinicRow(User $user, Clinic $clinic, Doctor $doctor): ?DoctorSecretary
{
return $this->findOneBy([
'secretary' => $user,
'clinic' => $clinic,
'doctor' => $doctor,
'ownerType' => DoctorSecretary::OWNER_CLINIC,
'active' => true,
]);
}
/** همه دکترهای کلینیک که این منشی به آن‌ها متصل است */
public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): array
{
return $this->createQueryBuilder('s')
return $this->getEntityManager()->createQueryBuilder()
->select('d')
->join('s.doctor', 'd')
->from(Doctor::class, 'd')
->join(DoctorSecretary::class, 's', 'WITH', 's.doctor = d')
->where('s.secretary = :user')
->andWhere('s.clinic = :clinic')
->andWhere('s.ownerType = :type')
@@ -122,6 +135,22 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
return $clinic->getDoctors()->contains($doctor);
}
/** روابط owner_type='clinic' یک منشی مشخص در یک کلینیک (فعال و غیرفعال، برای sync) */
public function findByClinicAndSecretary(Clinic $clinic, User $secretary): array
{
return $this->createQueryBuilder('s')
->addSelect('doc')
->join('s.doctor', 'doc')
->where('s.clinic = :clinic')
->andWhere('s.secretary = :secretary')
->andWhere('s.ownerType = :type')
->setParameter('clinic', $clinic)
->setParameter('secretary', $secretary)
->setParameter('type', DoctorSecretary::OWNER_CLINIC)
->getQuery()
->getResult();
}
/** همه منشی ها کلینیک (owner_type='clinic') */
public function findByClinic(Clinic $clinic): array
{
+207
View File
@@ -0,0 +1,207 @@
<?php
namespace App\Secretary\Service;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use App\Subscription\Service\SubscriptionService;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Secretary assignment logic shared by single- and multi-doctor flows.
*
* A secretary is a User linked to one or more doctors via DoctorSecretary rows
* (one row per doctor). Clinic-owned assignment lets a clinic manager attach the
* same secretary to several of the clinic's doctors at once and later re-sync
* that set; the secretary's access is scoped to exactly those rows.
*/
class SecretaryService
{
public function __construct(
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly DoctorRepository $doctorRepo,
private readonly UserRepository $userRepo,
private readonly UserPasswordHasherInterface $hasher,
private readonly SubscriptionService $subscriptionService,
private readonly SmsService $smsService,
private readonly string $appUrl,
) {}
/** Find the secretary User by mobile or create it; ensure ROLE_SECRETARY, apply name/password. */
public function resolveSecretaryUser(string $mobile, ?string $name = null, ?string $password = null): User
{
$user = $this->userRepo->findByMobile($mobile);
if ($user === null) {
$user = new User($mobile);
if (!empty($password)) {
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
}
}
if (!empty($name)) {
$user->setRealName(trim($name));
}
$roles = $user->getRoles();
if (!in_array('ROLE_SECRETARY', $roles, true)) {
$roles[] = 'ROLE_SECRETARY';
$user->setRoles(array_values(array_unique($roles)));
}
$this->userRepo->save($user);
return $user;
}
public function doctorAtSecretaryLimit(Doctor $doctor): bool
{
$limit = $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId());
return $this->secretaryRepo->countActiveByDoctor($doctor) >= $limit;
}
/**
* Assign a secretary (by mobile) to several clinic doctors atomically.
*
* @param string[] $doctorUuids
* @return array{secretary: User, created: DoctorSecretary[], skipped_duplicate: string[], skipped_limit: string[], skipped_not_in_clinic: string[]}
*/
public function assignToClinicDoctors(Clinic $clinic, string $mobile, array $doctorUuids, array $meta = []): array
{
$secretary = $this->resolveSecretaryUser($mobile, $meta['name'] ?? null, $meta['password'] ?? null);
$created = $skippedDup = $skippedLimit = $skippedNotInClinic = [];
foreach (array_unique($doctorUuids) as $doctorUuid) {
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) {
$skippedNotInClinic[] = $doctorUuid;
continue;
}
$existing = $this->secretaryRepo->findOneBy([
'doctor' => $doctor,
'secretary' => $secretary,
'ownerType' => DoctorSecretary::OWNER_CLINIC,
]);
if ($existing !== null) {
if ($existing->isActive()) {
$skippedDup[] = $doctorUuid;
continue;
}
$existing->setActive(true);
$this->applyMeta($existing, $meta);
$created[] = $existing;
continue;
}
if ($this->doctorAtSecretaryLimit($doctor)) {
$skippedLimit[] = $doctorUuid;
continue;
}
$row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
$this->applyMeta($row, $meta);
$this->secretaryRepo->save($row, false);
$created[] = $row;
}
$this->secretaryRepo->getEntityManager()->flush(); // flush the batch of new rows
if (!empty($created)) {
$this->sendWelcomeSms($mobile, $clinic->getName() ?? 'کلینیک');
}
return [
'secretary' => $secretary,
'created' => $created,
'skipped_duplicate' => $skippedDup,
'skipped_limit' => $skippedLimit,
'skipped_not_in_clinic' => $skippedNotInClinic,
];
}
/**
* Re-sync a clinic secretary's assigned doctors to exactly $doctorUuids:
* activate/create the wanted set, deactivate the rest.
*
* @param string[] $doctorUuids
* @return array{added: DoctorSecretary[], removed: DoctorSecretary[], skipped_limit: string[], skipped_not_in_clinic: string[]}
*/
public function syncClinicDoctors(Clinic $clinic, User $secretary, array $doctorUuids): array
{
$wanted = array_values(array_unique($doctorUuids));
$existing = $this->secretaryRepo->findByClinicAndSecretary($clinic, $secretary);
$byDoctorUuid = [];
foreach ($existing as $row) {
$byDoctorUuid[$row->getDoctor()->getUuid()] = $row;
}
$added = $removed = $skippedLimit = $skippedNotInClinic = [];
foreach ($wanted as $doctorUuid) {
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) {
$skippedNotInClinic[] = $doctorUuid;
continue;
}
$current = $byDoctorUuid[$doctorUuid] ?? null;
if ($current !== null) {
if (!$current->isActive()) {
$current->setActive(true);
$added[] = $current;
}
continue;
}
if ($this->doctorAtSecretaryLimit($doctor)) {
$skippedLimit[] = $doctorUuid;
continue;
}
$row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
$this->secretaryRepo->save($row, false);
$added[] = $row;
}
foreach ($existing as $row) {
if ($row->isActive() && !in_array($row->getDoctor()->getUuid(), $wanted, true)) {
$row->setActive(false);
$removed[] = $row;
}
}
$this->secretaryRepo->getEntityManager()->flush();
return [
'added' => $added,
'removed' => $removed,
'skipped_limit' => $skippedLimit,
'skipped_not_in_clinic' => $skippedNotInClinic,
];
}
private function applyMeta(DoctorSecretary $row, array $meta): void
{
if (array_key_exists('national_code', $meta)) {
$row->setNationalCode($meta['national_code'] !== null ? trim((string) $meta['national_code']) : null);
}
if (array_key_exists('address', $meta)) {
$row->setAddress($meta['address'] !== null ? trim((string) $meta['address']) : null);
}
if (!empty($meta['permissions'])) {
$row->mergePermissions($meta['permissions']);
}
}
private function sendWelcomeSms(string $mobile, string $ownerName): void
{
$this->smsService->dispatchTemplate(SmsLog::TAG_SECRETARY, $mobile, [
'owner' => $ownerName,
'username' => $mobile,
'link' => rtrim($this->appUrl, '/') . '/login',
]);
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Tests\Secretary;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Auth\Entity\User;
use App\Tests\ApiTestCase;
/**
* A clinic manager can assign one secretary to several of the clinic's doctors
* at once (many DoctorSecretary rows for one secretary User) and later re-sync
* that set. Doctors outside the clinic are rejected; foreign owners are 403.
*/
class ClinicSharedSecretaryTest extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: Doctor[]} */
private function makeClinicWithDoctors(int $count): array
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctors = [];
for ($i = 0; $i < $count; $i++) {
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), "دکتر $i");
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$doctors[] = $doctor;
}
$this->em->flush();
return [$owner, $clinic, $doctors];
}
private function mobile(): string
{
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
}
public function testClinicOwnerAssignsSecretaryToMultipleDoctors(): void
{
[$owner, , $doctors] = $this->makeClinicWithDoctors(3);
$body = $this->authJson('POST', '/api/v1/secretary', $owner, [
'mobile_number' => $this->mobile(),
'name' => 'منشی مشترک',
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
]);
$this->assertSame(201, $this->responseCode());
$this->assertCount(2, $body['data']['created']);
$this->assertSame([], $body['data']['skipped_not_in_clinic']);
$this->assertNotEmpty($body['data']['secretary_uuid']);
}
public function testSkipsDuplicatesOnReassign(): void
{
[$owner, , $doctors] = $this->makeClinicWithDoctors(2);
$mobile = $this->mobile();
$uuids = [$doctors[0]->getUuid(), $doctors[1]->getUuid()];
$this->authJson('POST', '/api/v1/secretary', $owner, ['mobile_number' => $mobile, 'doctor_uuids' => $uuids]);
$body = $this->authJson('POST', '/api/v1/secretary', $owner, ['mobile_number' => $mobile, 'doctor_uuids' => $uuids]);
$this->assertSame(201, $this->responseCode());
$this->assertCount(0, $body['data']['created']);
$this->assertCount(2, $body['data']['skipped_duplicate']);
}
public function testRejectsDoctorOutsideClinic(): void
{
[$owner, , $doctors] = $this->makeClinicWithDoctors(1);
$foreignDoctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'خارج از کلینیک');
$this->em->persist($foreignDoctor);
$this->em->flush();
$body = $this->authJson('POST', '/api/v1/secretary', $owner, [
'mobile_number' => $this->mobile(),
'doctor_uuids' => [$doctors[0]->getUuid(), $foreignDoctor->getUuid()],
]);
$this->assertSame(201, $this->responseCode());
$this->assertCount(1, $body['data']['created']);
$this->assertContains($foreignDoctor->getUuid(), $body['data']['skipped_not_in_clinic']);
}
public function testSyncAddsAndRemovesDoctors(): void
{
[$owner, $clinic, $doctors] = $this->makeClinicWithDoctors(3);
$mobile = $this->mobile();
$assigned = $this->authJson('POST', '/api/v1/secretary', $owner, [
'mobile_number' => $mobile,
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
]);
$secretaryUuid = $assigned['data']['secretary_uuid'];
// sync to {doctor1, doctor2} → drop doctor0, add doctor2
$body = $this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $owner, [
'secretary_uuid' => $secretaryUuid,
'doctor_uuids' => [$doctors[1]->getUuid(), $doctors[2]->getUuid()],
]);
$this->assertSame(200, $this->responseCode());
$this->assertSame(1, $body['data']['added']); // doctor2
$this->assertSame(1, $body['data']['removed']); // doctor0
}
public function testForeignOwnerCannotSync(): void
{
[, $clinic, ] = $this->makeClinicWithDoctors(1);
$intruder = $this->createUser(['ROLE_CLINIC']);
$this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $intruder, [
'secretary_uuid' => 'whatever',
'doctor_uuids' => [],
]);
$this->assertSame(403, $this->responseCode());
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Tests\Secretary;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* A clinic-owned secretary must only see the appointments of the doctors they
* are actually assigned to — not every doctor in the clinic.
*/
class SecretaryAppointmentScopeTest extends ApiTestCase
{
public function testSecretarySeesOnlyAssignedDoctorsAppointments(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctorA = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر A');
$doctorB = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر B');
$this->em->persist($doctorA);
$this->em->persist($doctorB);
$clinic->getDoctors()->add($doctorA);
$clinic->getDoctors()->add($doctorB);
// منشی فقط به دکتر A تخصیص داده شده
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
$rel = new DoctorSecretary($doctorA, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic);
$this->em->persist($rel);
// scope فعالِ منشی = این کلینیک
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
// یک نوبت برای هر پزشک
$patient = $this->createUser(['ROLE_USER']);
$start = time() + 3600;
$this->em->persist(new Appointment($doctorA, $patient, $start, $start + 900));
$this->em->persist(new Appointment($doctorB, $patient, $start + 1800, $start + 2700));
$this->em->flush();
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
$this->assertSame(200, $this->responseCode());
// فقط نوبت دکتر A دیده می‌شود، نه دکتر B
$this->assertSame(1, $body['meta']['totalRecords']);
}
public function testSecretaryWithNoAssignmentSeesNothing(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تنها');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
// منشی context کلینیک دارد ولی رابطه‌ی فعال ندارد
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
$patient = $this->createUser(['ROLE_USER']);
$start = time() + 3600;
$this->em->persist(new Appointment($doctor, $patient, $start, $start + 900));
$this->em->flush();
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
// بدون رابطه‌ی فعال → resolveSecretaryFilter=null → لیست خالی
$this->assertSame(0, $body['meta']['totalRecords']);
}
}