diff --git a/.claude/prompt/fix-access-scoping-doctor-clinic-representation.md b/.claude/prompt/fix-access-scoping-doctor-clinic-representation.md new file mode 100644 index 00000000..41209798 --- /dev/null +++ b/.claude/prompt/fix-access-scoping-doctor-clinic-representation.md @@ -0,0 +1,167 @@ +# رفع مشکلات دسترسی: پزشکِ عضو کلینیک + scope نماینده + +## پروژه + +`clinicpro` (Backend auth/permission + Admin React SPA). کاملاً داخل همین پروژه است. + +## زمینه + +دو نشتیِ دسترسی در پنل ادمین وجود دارد: + +1. **پزشکِ عضو کلینیک، دسترسی کاملِ مالک کلینیک می‌گیرد.** وقتی یک پزشک از طریق دعوتنامه به کلینیک اضافه می‌شود، در `buildAvailableContexts` برایش یک context با `'role' => 'clinic'` ساخته می‌شود. چون `switchContext` در فرانت `primaryRole = context.role` می‌گذارد، آن پزشک با سوییچ به این context عملاً «مالک کلینیک» می‌شود و به **کل پنل کلینیک** (پرسنل، مالی، خدمات، اشتراک، ویرایش کلینیک و...) دسترسی پیدا می‌کند. درست این است که او فقط «پزشکِ شاغل در آن کلینیک» باشد (نوبت‌های خودش در آن کلینیک)، نه مدیر کلینیک. + +2. **نماینده همه‌ی پزشکان/کلینیک‌ها را می‌بیند، نه فقط مالِ خودش.** صفحات `DoctorsPage`/`ClinicsPage` در حالت نماینده از endpointهای **عمومی** `/api/v1/doctors` و `/api/v1/clinics` استفاده می‌کنند که هیچ فیلتری روی `representation_id` ندارند. ضمناً `RepresentationActionController::createClinic` هنگام ساخت کلینیک، `representation_id` را ست **نمی‌کند** (فقط createDoctor این کار را می‌کند). نتیجه: نماینده همه‌ی دکترها/کلینیک‌های سیستم را می‌بیند و کلینیک‌های خودش هم تگ‌گذاری نمی‌شوند. + +## مشکل / هدف + +- پزشکِ عضو کلینیک نباید نقش/دسترسی مالک کلینیک بگیرد. +- نماینده فقط باید پزشکان و کلینیک‌هایی را ببیند که `representation_id` آن‌ها = id همان نماینده است (همان‌هایی که خودش ثبت کرده). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Auth/Controller/AuthController.php` | `buildAvailableContexts()` — ساخت context پزشکِ عضو کلینیک با role اشتباه `clinic` | +| `assets/admin/stores/authStore.ts` | `switchContext` → `primaryRole = context.role`؛ و type نقش | +| `assets/admin/components/layout/Sidebar.tsx` | منوی هر نقش (باید برای پزشکِ مهمانِ کلینیک محدود باشد) | +| `assets/admin/App.tsx` | `RoleRoute` صفحات کلینیک | +| `src/Representation/Controller/RepresentationActionController.php` | `createClinic` که `representation_id` ست نمی‌کند؛ مرجع `createDoctor` که می‌کند | +| `src/Doctor/Repository/DoctorRepository.php` | `findWithFilters` — فاقد فیلتر `representation` | +| `src/Clinic/Repository/ClinicRepository.php` | `findWithFilters` — فاقد فیلتر `representation` | +| `src/Doctor/Controller/DoctorController.php` / `src/Clinic/Controller/ClinicController.php` | endpointهای عمومی `GET /api/v1/doctors` و `/api/v1/clinics` | +| `assets/admin/pages/DoctorsPage.tsx` / `ClinicsPage.tsx` | لیست نماینده که از endpoint عمومی بدون scope استفاده می‌کند | +| `docs/api/auth.md`, `docs/api/doctor.md`, `docs/api/clinic.md`, `docs/api/representation.md` | مستندسازی | + +## وضعیت فعلی (کد واقعی) + +`buildAvailableContexts` — context پزشکِ عضو کلینیک با role اشتباه: +```php +if ($doctor = $this->doctorRepo->findByUser($user)) { + $contexts[] = [ 'type'=>'doctor', 'db_uuid'=>$doctor->getUuid(), 'name'=>'مطب شخصی '.$doctor->getName(), 'role'=>'doctor' ]; + foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName() ?? '', + 'role' => 'clinic', // ← اشتباه: پزشکِ عضو، نقش مالک کلینیک می‌گیرد + 'doctor_uuid' => $doctor->getUuid(), + ]; + } +} + +// مالک واقعی کلینیک (این درست است — فقط وقتی clinic.user === خود کاربر) +if ($clinic = $this->clinicRepo->findByUser($user)) { ... 'role'=>'clinic' ... } +``` + +`switchContext` در authStore (نقش از context می‌آید): +```ts +primaryRole: res.data.context?.role ?? null, +``` + +`RepresentationActionController::createClinic` — بدون ست representation_id: +```php +$clinic = new Clinic($ownerUser); +$clinic->setName($name); +// ... telephone/address/info +$this->em->persist($clinic); // ← representation_id ست نمی‌شود +``` +(در مقابل، `createDoctor` این را دارد: `$doctor->setRepresentationId($rep->getId());`) + +`DoctorsPage`/`ClinicsPage` (حالت نماینده، بدون scope): +```ts +const base = isRepresentation ? '/api/v1/doctors' : '/api/v1/admin/doctors'; // عمومی، بدون representation +const listBase = isRepresentation ? '/api/v1/clinics' : '/api/v1/admin/clinics'; // عمومی، بدون representation +``` + +## وظایف + +### ۱. نقشِ محدود برای پزشکِ عضو کلینیک (رفع نشتی دسترسی) + +در `buildAvailableContexts`، context کلینیک برای **پزشکِ عضو** باید نقش مالک کلینیک ندهد. یک نقش/scope محدود بده، مثلاً `role => 'doctor'` با `scope => 'clinic'` (یعنی همان پزشک، ولی در محیط آن کلینیک — برای دیدن نوبت‌های خودش در آن کلینیک): + +```php +foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { + $contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName() ?? '', + 'role' => 'doctor', // پزشک می‌ماند، نه مالک کلینیک + 'scope' => 'clinic', + 'doctor_uuid' => $doctor->getUuid(), + ]; +} +``` +- بخش «صاحب کلینیک» (`$this->clinicRepo->findByUser($user)`) باید **دست‌نخورده** بماند و همان `role => 'clinic'` را داشته باشد — فقط مالک واقعی، نقش کامل کلینیک می‌گیرد. +- **نکته‌ی مهم Backend:** هر endpointی که فرض می‌کند «کاربرِ با context کلینیک = مالک کلینیک» باید بازبینی شود تا با scope محدود سازگار باشد (دسترسی نوشتن روی پرسنل/خدمات/اشتراک/ویرایش کلینیک نباید برای پزشکِ مهمان باز باشد). مالکیت را با `clinic.getUser()->getId() === $user->getId()` چک کن (الگوی موجود در `ClinicInvitationController` خط ۲۰۰). + +### ۲. Admin SPA — منو و مسیرهای محدود برای پزشکِ مهمانِ کلینیک + +چون با اصلاح وظیفه ۱ نقش این کاربر در آن context `doctor` می‌شود (نه `clinic`)، `Sidebar.tsx` و `RoleRoute`های `App.tsx` خودبه‌خود منوی پزشک را نشان می‌دهند و صفحات اختصاصی کلینیک (`/admin/staff`, `/admin/my-financial` با نقش clinic، ویرایش کلینیک، اشتراک) برای او باز نمی‌شوند. این را **تأیید کن**: +- در `App.tsx` مطمئن شو صفحاتی مثل `clinics/:uuid` (ClinicDetailPage)، `staff`, `subscription` برای نقش `doctor` فقط در حدی باز است که منطقی است (مشاهده، نه مدیریت). اگر صفحه‌ای با `roles={['doctor','clinic']}` هست که نباید برای پزشکِ مهمان نوشتنی باشد، در همان صفحه بر اساس مالکیت (context.role === 'clinic') دکمه‌های مدیریتی را پنهان کن. +- اگر `scope` در context هست، می‌توان در فرانت از `context.scope === 'clinic'` برای تمایز «پزشک در محیط کلینیک» استفاده کرد. + +### ۳. نماینده — ست‌کردن representation_id روی کلینیک + +در `RepresentationActionController::createClinic`، مثل createDoctor مالکیت نماینده را ست کن: +```php +$clinic = new Clinic($ownerUser); +$clinic->setName($name); +// ... +$rep = $this->representationRepo->findByUser($user); +if ($rep !== null) { + $clinic->setRepresentationId($rep->getId()); +} +$this->em->persist($clinic); +``` + +### ۴. فیلتر `representation` در لیست پزشکان و کلینیک‌ها + +در `DoctorRepository::findWithFilters` و `ClinicRepository::findWithFilters` یک فیلتر اختیاری `representation` اضافه کن: +```php +// DoctorRepository +if (!empty($filters['representation'])) { + $qb->andWhere('d.representationId = :rep')->setParameter('rep', (int) $filters['representation']); +} +// ClinicRepository +if (!empty($filters['representation'])) { + $qb->andWhere('c.representationId = :rep')->setParameter('rep', (int) $filters['representation']); +} +``` +- در controllerهای عمومی `GET /api/v1/doctors` و `GET /api/v1/clinics`، پارامتر `representation` از query عبور داده شود به `findWithFilters` (همان الگوی بقیه‌ی فیلترها). چون این پارامتر اختیاری است، رفتار عمومی سایت تغییری نمی‌کند. + +> هشدار امنیتی: نماینده نباید بتواند با دست‌کاری `representation` در query، لیست نماینده‌ی دیگری را ببیند. بهترین کار: یک endpoint اختصاصیِ scoped برای نماینده بساز که id را از کاربر جاری می‌گیرد (مثل `/api/v1/representation/doctors` و `/api/v1/representation/clinics` در همان `RepresentationActionController` با `findByUser`)، نه از query. این امن‌تر از پارامتر عمومی است. (پیشنهادِ ترجیحی.) + +### ۵. Admin SPA — اتصال لیست‌های نماینده به endpoint scoped + +در `DoctorsPage.tsx` و `ClinicsPage.tsx` حالت نماینده را به endpoint scoped (وظیفه ۴) وصل کن: +```ts +// به‌جای /api/v1/doctors عمومی: +const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors'; +// به‌جای /api/v1/clinics عمومی: +const listBase = isRepresentation ? '/api/v1/representation/clinics' : '/api/v1/admin/clinics'; +``` +- adapter نگاشت شکل پاسخ (که قبلاً برای DoctorsPage اضافه شده) را با شکل خروجی endpoint جدید هماهنگ کن. ساده‌ترین کار: endpoint scoped همان شکل `/api/v1/admin/doctors` و `/api/v1/admin/clinics` را برگرداند تا نیازی به adapter نباشد. + +### ۶. داشبورد نماینده — اعداد فقط از دامنه‌ی خودش + +مطمئن شو کارت‌ها/آمار داشبورد نماینده (`RepresentationDashboard` در `DashboardPage.tsx`) از endpointهای نماینده می‌آیند که از قبل scoped هستند (`/representation/{uuid}/dashboard/*` و `/representation/appointments`). اگر شمارش «تعداد پزشکان من / کلینیک‌های من» اضافه می‌شود، از همان endpoint scoped جدید بگیر (نه عمومی). + +### ۷. مستندسازی + +- `docs/api/auth.md`: در توضیح `available_contexts`/`primary_role`، تفاوت «مالک کلینیک» (`role: clinic`) و «پزشکِ عضو در محیط کلینیک» (`role: doctor`, `scope: clinic`) را شرح بده. +- `docs/api/doctor.md` و `docs/api/clinic.md`: پارامتر/endpoint جدید فیلتر `representation`. +- `docs/api/representation.md`: endpointهای جدید `GET /api/v1/representation/doctors` و `/clinics` (اگر مسیر scoped انتخاب شد)؛ و اینکه createClinic حالا `representation_id` ست می‌کند. + +## نکات مهم + +- **امنیت اول:** مالکیت همیشه از `#[CurrentUser]` تعیین شود؛ هرگز به `representation` در query برای scope اعتماد نکن (وظیفه ۴ پیشنهادِ endpoint scoped را ترجیح می‌دهد). +- `Clinic.representationId` و `Doctor.representationId` ستون scalar هستند؛ فیلتر مستقیم روی همان ستون. +- نقش `clinic` در سیستم = «مالک/مدیر کلینیک». پزشکِ عضو هرگز نباید این نقش را در context بگیرد. +- تغییر `buildAvailableContexts` رفتار سوییچ context را برای پزشکانِ چندکلینیکه عوض می‌کند؛ تست کن که مالک واقعی کلینیک هنوز نقش کامل دارد و پزشکِ مهمان فقط نوبت‌های خودش را می‌بیند. +- بعد از تغییر هر controller/مسیر، `cache:clear` و به‌روزرسانی `docs/api/*` در همان session. +- migration لازم نیست (هر دو ستون `representation_id` از قبل وجود دارند؛ این تسک فقط منطق/فیلتر/نقش است). +- **تست‌ها** (با `lexik:jwt:generate-token`): + - پزشکی که عضو یک کلینیک است → `available_contexts` آن کلینیک باید `role: doctor`/`scope: clinic` باشد، نه `clinic`؛ و بعد از switch، منوی پزشک ببیند نه پنل کامل کلینیک. + - مالک واقعی کلینیک → همچنان `role: clinic` و دسترسی کامل. + - نماینده → `GET /api/v1/representation/doctors|clinics` فقط ردیف‌های با `representation_id = نماینده‌ی جاری`؛ و کلینیکِ تازه‌ساخته توسط نماینده باید `representation_id` داشته باشد. + - نماینده نتواند با تغییر query، داده‌ی نماینده‌ی دیگر را ببیند. diff --git a/assets/admin/pages/ClinicsPage.tsx b/assets/admin/pages/ClinicsPage.tsx index 12f0c0d2..610f8511 100644 --- a/assets/admin/pages/ClinicsPage.tsx +++ b/assets/admin/pages/ClinicsPage.tsx @@ -41,7 +41,7 @@ export default function ClinicsPage() { const [addOpen, setAddOpen] = useState(false); const limit = 15; - const listBase = isRepresentation ? '/api/v1/clinics' : '/api/v1/admin/clinics'; + const listBase = isRepresentation ? '/api/v1/representation/clinics' : '/api/v1/admin/clinics'; const { data, isLoading } = useQuery({ queryKey: ['admin-clinics', page, search, statusFilter, isRepresentation], queryFn: () => diff --git a/assets/admin/pages/DoctorsPage.tsx b/assets/admin/pages/DoctorsPage.tsx index e4276a22..728bab6f 100644 --- a/assets/admin/pages/DoctorsPage.tsx +++ b/assets/admin/pages/DoctorsPage.tsx @@ -133,7 +133,7 @@ export default function DoctorsPage() { if (search) p.set('search', search); if (status) p.set('status', status); if (specialtyId) p.set('specialty_id', specialtyId); - const base = isRepresentation ? '/api/v1/doctors' : '/api/v1/admin/doctors'; + const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors'; return api.get>(`${base}?${p}`); }, }); @@ -141,26 +141,8 @@ export default function DoctorsPage() { const stats: DoctorStats | undefined = useMemo( () => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data] ); - // endpoint عمومی /doctors شکل متفاوتی دارد؛ به شکل AdminDoctor نگاشت می‌شود تا جدول کرش نکند. - const rawItems = doctorsQ.data?.data ?? []; - const items: AdminDoctor[] = isRepresentation - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ? (rawItems as any[]).map((d) => ({ - uuid: d.uuid, - id: d.id, - name: d.name, - gender: d.gender ?? null, - degree: d.degree ?? null, - medical_code: d.medical_code ?? null, - mobile: d.mobile ?? null, - email: d.email ?? null, - is_active: d.is_active ?? d.active ?? false, - rate: Number(d.rate ?? d.point ?? 0) || 0, - specialties: d.specialties ?? [], - profile_image: d.profile_image ?? d.img?.[0]?.url ?? null, - created_at: d.created_at ?? null, - }) as AdminDoctor) - : (rawItems as AdminDoctor[]); + // هم endpoint ادمین و هم endpoint نماینده شکل AdminDoctor را برمی‌گردانند. + const items: AdminDoctor[] = doctorsQ.data?.data ?? []; const total = doctorsQ.data?.meta?.totalRecords ?? 0; const specialties: SpecialtyOption[] = useMemo( () => (specialtiesQ.data?.data as any)?.data ?? specialtiesQ.data?.data ?? [], diff --git a/docs/api/auth.md b/docs/api/auth.md index f2b2c42f..4ed25cb6 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -293,8 +293,8 @@ Authorization: Bearer **قانون `context.role`** — نقشی که در آن محیط کاری فعال است: - context مطب شخصی دکتر: `"doctor"` -- context کلینیک که دکتر **عضو** آن است: `"clinic"` (read-only — دسترسی به اطلاعات کلینیک) -- context کلینیک که دکتر **صاحب** آن است: `"clinic"` +- context کلینیک که دکتر **عضو** آن است (مالک نیست): `"doctor"` + `"scope": "clinic"` — پزشک می‌ماند و فقط نوبت‌های خودش در آن کلینیک را می‌بیند؛ دسترسی مدیریتی پنل کلینیک ندارد +- context کلینیک که دکتر **صاحب** آن است: `"clinic"` (دسترسی کامل مالک) - context منشی: `"secretary"` > **نکته frontend:** پس از `switchContext`، `primaryRole` در store باید از `context.role` آپدیت شود. diff --git a/docs/api/representation.md b/docs/api/representation.md index cbfdefa1..90a0b0de 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -294,7 +294,7 @@ Get yearly earnings dashboard for a representation. ### POST `/api/v1/representation/clinic` -افزودن کلینیک توسط نماینده. +افزودن کلینیک توسط نماینده. `representation_id` کلینیک خودکار روی نماینده‌ی کاربر جاری ست می‌شود (مثل createDoctor) تا در لیست‌های scoped دیده شود. #### Request Body ```json @@ -357,3 +357,72 @@ Get yearly earnings dashboard for a representation. | Code | HTTP | Description | |------|------|-------------| | `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست | + +--- + +### GET `/api/v1/representation/doctors` + +پزشکانِ ثبت‌شده توسط نماینده‌ی جاری (فقط ردیف‌های `representation_id = نماینده‌ی کاربر جاری`). شکل آیتم یکسان با `GET /api/v1/admin/doctors` است. + +> **Permission:** `ROLE_REPRESENTATION` — id نماینده از `#[CurrentUser]` تعیین می‌شود، نه از query (نماینده نمی‌تواند داده‌ی نماینده‌ی دیگر را ببیند). + +#### Query Parameters +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `page` | integer | ❌ | پیش‌فرض 1 | +| `limit` | integer | ❌ | پیش‌فرض 15، حداکثر 100 | +| `search` | string | ❌ | جستجو در نام یا موبایل پزشک | + +#### Response `200` +```json +{ + "success": true, + "data": [ + { + "uuid": "...", "id": 12, "name": "دکتر ...", "gender": "man", "degree": "...", + "medical_code": "...", "mobile": "0912...", "email": null, + "is_active": true, "rate": 3.5, "specialties": [], + "profile_image": null, "created_at": "2026-06-18T..." + } + ], + "meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 } +} +``` +#### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست | + +--- + +### GET `/api/v1/representation/clinics` + +کلینیک‌های ثبت‌شده توسط نماینده‌ی جاری (فقط `representation_id = نماینده‌ی کاربر جاری`). شکل آیتم سازگار با `GET /api/v1/admin/clinics`. + +> **Permission:** `ROLE_REPRESENTATION` — id نماینده از `#[CurrentUser]`. + +#### Query Parameters +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `page` | integer | ❌ | پیش‌فرض 1 | +| `limit` | integer | ❌ | پیش‌فرض 15، حداکثر 100 | +| `search` | string | ❌ | جستجو در نام یا تلفن کلینیک | + +#### Response `200` +```json +{ + "success": true, + "data": [ + { + "uuid": "...", "id": 5, "name": "کلینیک ...", "telephone": "...", + "logo": null, "clinic_logo": null, "is_active": true, + "doctors_count": 0, "created_at": "2026-06-18T..." + } + ], + "meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 } +} +``` +#### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_NOT_FOUND_001` | 404 | کاربر جاری نماینده نیست | diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index cc6a0f8a..8c1a4d4f 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -627,11 +627,16 @@ class AuthController extends BaseController 'role' => 'doctor', ]; foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { + // پزشکِ عضو کلینیک «مالک» نیست؛ نقش doctor با scope کلینیک می‌گیرد تا + // فقط نوبت‌های خودش در آن کلینیک را ببیند، نه دسترسی کامل پنل کلینیک. + // اگر همین پزشک مالک کلینیک باشد، نقش کامل clinic در بلوک مالک پایین ست می‌شود. + $isOwner = $clinic->getUser()->getId() === $user->getId(); $contexts[] = [ 'type' => 'clinic', 'db_uuid' => $clinic->getUuid(), 'name' => $clinic->getName() ?? '', - 'role' => 'clinic', + 'role' => $isOwner ? 'clinic' : 'doctor', + 'scope' => $isOwner ? null : 'clinic', 'doctor_uuid' => $doctor->getUuid(), ]; } diff --git a/src/Representation/Controller/RepresentationActionController.php b/src/Representation/Controller/RepresentationActionController.php index 9cf5392f..0e1cfad2 100644 --- a/src/Representation/Controller/RepresentationActionController.php +++ b/src/Representation/Controller/RepresentationActionController.php @@ -151,7 +151,7 @@ class RepresentationActionController extends BaseController ] )] #[Route('/api/v1/representation/clinic', methods: ['POST'])] - public function createClinic(Request $request): JsonResponse + public function createClinic(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $mobile = trim((string) ($data['owner_mobile'] ?? '')); @@ -182,6 +182,11 @@ class RepresentationActionController extends BaseController if (!empty($data['address'])) $clinic->setAddress($data['address']); if (!empty($data['info'])) $clinic->setInfo($data['info']); + $rep = $this->representationRepo->findByUser($user); + if ($rep !== null) { + $clinic->setRepresentationId($rep->getId()); + } + $this->em->persist($clinic); $this->em->flush(); @@ -192,6 +197,116 @@ class RepresentationActionController extends BaseController ]); } + #[OA\Get( + path: '/api/v1/representation/doctors', + summary: 'پزشکانِ ثبت‌شده توسط نماینده‌ی جاری (paginated، شکلِ یکسان با /admin/doctors)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + ], + responses: [new OA\Response(response: 200, description: 'لیست پزشکان نماینده')] + )] + #[Route('/api/v1/representation/doctors', methods: ['GET'])] + public function doctors(Request $request, #[CurrentUser] User $user): JsonResponse + { + $rep = $this->representationRepo->findByUser($user); + if ($rep === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده‌ای برای این کاربر یافت نشد', 404); + } + + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 15))); + $search = trim((string) $request->query->get('search', '')); + + $qb = $this->em->createQueryBuilder() + ->select('d.uuid, d.id, d.name, d.gender, d.degree, d.medicalSystemCode, d.mobileNumber, d.images, d.doctorRate, d.activeDoctorAppointment, d.createdAt') + ->from(Doctor::class, 'd') + ->where('d.representationId = :repId') + ->setParameter('repId', $rep->getId()) + ->orderBy('d.createdAt', 'DESC'); + + if ($search !== '') { + $qb->andWhere('d.name LIKE :s OR d.mobileNumber LIKE :s')->setParameter('s', '%' . $search . '%'); + } + + $total = (clone $qb)->select('COUNT(d.id)')->getQuery()->getSingleScalarResult(); + $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) + ->getQuery()->getArrayResult(); + + $items = array_map(fn(array $d) => [ + 'uuid' => $d['uuid'], + 'id' => (int) $d['id'], + 'name' => $d['name'], + 'gender' => $d['gender'], + 'degree' => $d['degree'], + 'medical_code' => $d['medicalSystemCode'], + 'mobile' => $d['mobileNumber'], + 'email' => null, + 'is_active' => (bool) $d['activeDoctorAppointment'], + 'rate' => (float) $d['doctorRate'], + 'specialties' => [], + 'profile_image' => !empty($d['images']) ? ($d['images'][0]['url'] ?? null) : null, + 'created_at' => date('c', (int) $d['createdAt']), + ], $rows); + + return $this->paginated($items, (int) $total, $page, $limit); + } + + #[OA\Get( + path: '/api/v1/representation/clinics', + summary: 'کلینیک‌های ثبت‌شده توسط نماینده‌ی جاری (paginated، شکلِ یکسان با /admin/clinics)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + ], + responses: [new OA\Response(response: 200, description: 'لیست کلینیک‌های نماینده')] + )] + #[Route('/api/v1/representation/clinics', methods: ['GET'])] + public function clinics(Request $request, #[CurrentUser] User $user): JsonResponse + { + $rep = $this->representationRepo->findByUser($user); + if ($rep === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده‌ای برای این کاربر یافت نشد', 404); + } + + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 15))); + $search = trim((string) $request->query->get('search', '')); + + $qb = $this->em->createQueryBuilder() + ->select('c.uuid, c.id, c.name, c.telephone, c.clinicLogo, c.isActive, c.createdAt') + ->from(Clinic::class, 'c') + ->where('c.representationId = :repId') + ->setParameter('repId', $rep->getId()) + ->orderBy('c.createdAt', 'DESC'); + + if ($search !== '') { + $qb->andWhere('c.name LIKE :s OR c.telephone LIKE :s')->setParameter('s', '%' . $search . '%'); + } + + $total = (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult(); + $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit) + ->getQuery()->getArrayResult(); + + $items = array_map(fn(array $c) => [ + 'uuid' => $c['uuid'], + 'id' => (int) $c['id'], + 'name' => $c['name'], + 'telephone' => $c['telephone'], + 'logo' => $c['clinicLogo'], + 'clinic_logo' => $c['clinicLogo'], + 'is_active' => (bool) $c['isActive'], + 'doctors_count'=> 0, + 'created_at' => date('c', (int) $c['createdAt']), + ], $rows); + + return $this->paginated($items, (int) $total, $page, $limit); + } + #[OA\Get( path: '/api/v1/representation/appointments', summary: 'نوبت‌های پزشکانِ زیرمجموعه‌ی نماینده‌ی جاری (paginated)',