feat: fix access scoping for clinic member doctors and add representation_id handling for clinics
This commit is contained in:
@@ -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، دادهی نمایندهی دیگر را ببیند.
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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<PaginatedResponse<AdminDoctor>>(`${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 ?? [],
|
||||
|
||||
+2
-2
@@ -293,8 +293,8 @@ Authorization: Bearer <token>
|
||||
|
||||
**قانون `context.role`** — نقشی که در آن محیط کاری فعال است:
|
||||
- context مطب شخصی دکتر: `"doctor"`
|
||||
- context کلینیک که دکتر **عضو** آن است: `"clinic"` (read-only — دسترسی به اطلاعات کلینیک)
|
||||
- context کلینیک که دکتر **صاحب** آن است: `"clinic"`
|
||||
- context کلینیک که دکتر **عضو** آن است (مالک نیست): `"doctor"` + `"scope": "clinic"` — پزشک میماند و فقط نوبتهای خودش در آن کلینیک را میبیند؛ دسترسی مدیریتی پنل کلینیک ندارد
|
||||
- context کلینیک که دکتر **صاحب** آن است: `"clinic"` (دسترسی کامل مالک)
|
||||
- context منشی: `"secretary"`
|
||||
|
||||
> **نکته frontend:** پس از `switchContext`، `primaryRole` در store باید از `context.role` آپدیت شود.
|
||||
|
||||
@@ -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 | کاربر جاری نماینده نیست |
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user