- Updated authStore to include 'representation' role. - Modified DoctorFormPage and DoctorsPage to handle different endpoints based on user role. - Created new RepresentationActionController for handling doctor and clinic creation by representatives. - Added new API endpoints for representatives to manage doctors, clinics, and view appointments. - Updated documentation to reflect new role and API changes.
224 lines
16 KiB
Markdown
224 lines
16 KiB
Markdown
# دسترسی نقش نماینده (ROLE_REPRESENTATION) به پنل ادمین
|
||
|
||
## پروژه
|
||
|
||
`clinicpro` (Backend + Admin React SPA). کاملاً داخل همین پروژه است؛ پرامپت همتای frontend عمومی لازم نیست.
|
||
|
||
## زمینه
|
||
|
||
کاربرانِ دارای نقش `ROLE_REPRESENTATION` (نمایندهی فروش/شهر) باید بتوانند وارد پنل ادمین (`https://clinic-pro.ddev.site/admin/dashboard`) شوند و کارهای محدودِ خودشان را انجام دهند:
|
||
|
||
1. **افزودن پزشک**
|
||
2. **افزودن کلینیک**
|
||
3. **دیدن نوبتهای پزشکانی که زیرمجموعهی همان نمایندهاند** (پزشکانی که `Doctor.representationId` = id همان نماینده است)
|
||
4. **داشبورد اختصاصی خودشان** (درآمد/کمیسیون ماهانه و سالانه که از قبل موجود است)
|
||
|
||
الان این نقش عملاً به پنل راه ندارد و حتی اگر داشته باشد همهچیز مسدود است. شکافهای دقیق:
|
||
|
||
- `App\Auth\Controller\AuthController::resolvePrimaryRole()` هیچ شاخهای برای `ROLE_REPRESENTATION` ندارد → چنین کاربری `primary_role: 'user'` میگیرد و پنل او را نمیشناسد.
|
||
- `App\Admin\Controller\AdminApiController` در سطح کلاس `#[IsGranted('ROLE_ADMIN')]` است → endpointهای `createDoctor` (`POST /api/v1/admin/doctors`) و `createClinic` (`POST /api/v1/admin/clinic`) برای نماینده مسدودند.
|
||
- Admin SPA: `assets/admin/App.tsx` تابع `RoleRoute` فقط `['admin']` را برای کلینیکها/پزشکان میپذیرد؛ `Sidebar.tsx` فقط برای `admin|clinic|doctor|secretary` منو دارد (نماینده ندارد)؛ `DashboardPage.tsx` فقط endpointهای `/api/v1/admin/dashboard/*` را صدا میزند (admin-only).
|
||
- هیچ endpointی برای «نوبتهای پزشکانِ یک نماینده» وجود ندارد.
|
||
|
||
## مشکل / هدف
|
||
|
||
به نقش `ROLE_REPRESENTATION` اجازهی ورود به پنل و دسترسی به ۴ قابلیت بالا داده شود — بدون اینکه دسترسیهای ادمین (کاربران، پرداختها، تسویه، بلاگ و...) برای او باز شود.
|
||
|
||
## فایلهای مرتبط
|
||
|
||
| فایل | نقش |
|
||
|------|-----|
|
||
| `src/Auth/Controller/AuthController.php` | `resolvePrimaryRole()` و gate لاگین staff (`/oauth/userinfo` → `primary_role`) |
|
||
| `src/Admin/Controller/AdminApiController.php` | کلاس `#[IsGranted('ROLE_ADMIN')]`؛ متدهای `createDoctor`, `createClinic`, `appointments` |
|
||
| `src/Representation/Controller/RepresentationController.php` | الگوی permission نماینده (`$rep->getUser()->getId() === $user->getId()`)؛ dashboard ماهانه/سالانه |
|
||
| `src/Representation/Repository/RepresentationRepository.php` | یافتن نماینده از روی user (`findByUser`) |
|
||
| `src/Doctor/Entity/Doctor.php` | `representationId` (FK به نماینده) — مبنای «پزشکان این نماینده» |
|
||
| `src/Appointment/...` (Repository/Controller) | منبع لیست نوبتها برای endpoint جدید |
|
||
| `assets/admin/App.tsx` | `RoleRoute` + ثبت routeهای جدید |
|
||
| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش `representation` |
|
||
| `assets/admin/pages/DashboardPage.tsx` | شاخهی داشبورد نماینده |
|
||
| `assets/admin/pages/AppointmentsPage.tsx` | انتخاب endpoint نوبتها بر اساس نقش |
|
||
| `assets/admin/stores/authStore.ts` | `primaryRole` (از `primary_role` میآید — تغییر لازم نیست، فقط مقدار جدید) |
|
||
| `docs/api/representation.md`, `docs/api/admin.md`, `docs/api/auth.md` | مستندسازی |
|
||
|
||
## وضعیت فعلی (کد واقعی)
|
||
|
||
`resolvePrimaryRole` — بدون شاخهی نماینده:
|
||
```php
|
||
private function resolvePrimaryRole(User $user): string
|
||
{
|
||
$roles = $user->getRoles();
|
||
if (in_array('ROLE_ADMIN', $roles, true)) return 'admin';
|
||
if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic';
|
||
if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor';
|
||
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
|
||
return 'user';
|
||
}
|
||
```
|
||
|
||
`AdminApiController` — همه چیز admin-only:
|
||
```php
|
||
#[IsGranted('ROLE_ADMIN')]
|
||
class AdminApiController extends BaseController
|
||
{
|
||
// ... createDoctor(), createClinic(), appointments() همگی ذیل این قانوناند
|
||
}
|
||
```
|
||
|
||
`Doctor` — رابطه با نماینده:
|
||
```php
|
||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||
private ?int $representationId = null;
|
||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||
```
|
||
|
||
Admin SPA `App.tsx` — RoleRoute و routeهای فعلی:
|
||
```tsx
|
||
function RoleRoute({ roles, children }: { roles: string[]; children: React.ReactNode }) {
|
||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||
return <>{children}</>;
|
||
}
|
||
// ...
|
||
<Route path="dashboard" element={<DashboardPage />} /> {/* همه نقشها */}
|
||
<Route path="appointments" element={<AppointmentsPage />} /> {/* همه نقشها */}
|
||
<Route path="clinics" element={<RoleRoute roles={['admin']}><ClinicsPage /></RoleRoute>} />
|
||
// doctors هم اکنون فقط ذیل admin در دسترس است
|
||
```
|
||
|
||
`Sidebar.tsx` — فقط ۴ نقش:
|
||
```tsx
|
||
if (primaryRole === "admin") { ... }
|
||
if (primaryRole === "clinic") { ... }
|
||
if (primaryRole === "doctor") { ... }
|
||
if (primaryRole === "secretary") { ... }
|
||
// representation وجود ندارد
|
||
```
|
||
|
||
`DashboardPage.tsx` — admin-only endpoints:
|
||
```tsx
|
||
const statsQ = useQuery({ queryFn: () => api.get('/api/v1/admin/dashboard/stats') });
|
||
const chartsQ = useQuery({ queryFn: () => api.get(`/api/v1/admin/dashboard/charts?...`) });
|
||
const recentQ = useQuery({ queryFn: () => api.get('/api/v1/admin/dashboard/recent') });
|
||
```
|
||
|
||
`AppointmentsPage.tsx` — انتخاب endpoint فعلی بر اساس نقش:
|
||
```ts
|
||
const isAdmin = primaryRole === 'admin';
|
||
const apptEndpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
|
||
```
|
||
|
||
## وظایف
|
||
|
||
### ۱. Backend — شناختن نقش نماینده در `primary_role`
|
||
|
||
در `resolvePrimaryRole()` شاخهی نماینده را **قبل از `return 'user'`** اضافه کن (اولویت پایینتر از admin/clinic/doctor/secretary، چون یک کاربر ممکن است همزمان چند نقش داشته باشد):
|
||
|
||
```php
|
||
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
|
||
if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
|
||
return 'user';
|
||
```
|
||
|
||
اگر در `AuthController` یک gate صریح برای «نقش staff مجاز ورود» هست (لیست ROLE_ADMIN/DOCTOR/CLINIC/SECRETARY که کاربر عادی را رد میکند)، `ROLE_REPRESENTATION` را هم به آن لیست اضافه کن تا با `/oauth/token` + `/oauth/userinfo` وارد شود. (اگر چنین gateی وجود ندارد، نیازی نیست.)
|
||
|
||
### ۲. Backend — اجازهی createDoctor و createClinic به نماینده
|
||
|
||
`AdminApiController` در سطح کلاس `ROLE_ADMIN` است و این قانون بر method-level مقدم میشود؛ پس صرفِ گذاشتن قانونِ بازتر روی متد کافی نیست. **کمریسکترین راه: یک controller جدید بساز** — `src/Representation/Controller/RepresentationActionController.php` — با دو route نازک که منطق ساخت پزشک/کلینیک را اجرا کنند (یا منطق مشترک را به یک سرویس استخراج کن و هر دو controller از آن استفاده کنند تا کد تکراری نشود):
|
||
|
||
```php
|
||
#[IsGranted(new Expression("is_granted('ROLE_ADMIN') or is_granted('ROLE_REPRESENTATION')"))]
|
||
#[Route('/api/v1/representation/doctor', methods: ['POST'])]
|
||
public function createDoctor(Request $request, #[CurrentUser] User $user): JsonResponse { /* همان منطق createDoctor */ }
|
||
|
||
#[IsGranted(new Expression("is_granted('ROLE_ADMIN') or is_granted('ROLE_REPRESENTATION')"))]
|
||
#[Route('/api/v1/representation/clinic', methods: ['POST'])]
|
||
public function createClinic(Request $request, #[CurrentUser] User $user): JsonResponse { /* همان منطق createClinic */ }
|
||
```
|
||
- **مالکیت داده (مهم):** وقتی نماینده پزشک میسازد، نمایندهی کاربر جاری را با `RepresentationRepository::findByUser($user)` بگیر و `Doctor::setRepresentationId($rep->getId())` را ست کن تا بعداً در فیلتر «نوبتهای پزشکان من» دیده شود. برای کلینیک هم اگر فیلد مالکیت/شهر مرتبط هست همان را ست کن.
|
||
- بدنهی request و شکل پاسخ همان قرارداد فعلی `createDoctor`/`createClinic` در `docs/api/admin.md` بماند (تا فرمهای موجود admin بدون تغییر کار کنند).
|
||
- در Admin SPA، فرم افزودن پزشک/کلینیک برای نماینده باید این endpointهای جدید را صدا بزند و برای ادمین همان endpointهای `/api/v1/admin/*` را (انتخاب بر اساس `primaryRole`).
|
||
|
||
> اگر تیم ترجیح میدهد بهجای controller جدا، قانون کلاس `AdminApiController` را به Expression تبدیل کند: مراقب باش که در آن صورت **همهی** متدهای آن کلاس باز میشوند؛ پس باید روی تکتک متدهای admin-only دیگر `#[IsGranted('ROLE_ADMIN')]` گذاشته شود. این پرریسک است؛ controller جدا توصیه میشود.
|
||
|
||
### ۳. Backend — endpoint نوبتهای پزشکانِ نماینده
|
||
|
||
```
|
||
GET /api/v1/representation/appointments?page=&limit=&status=&from=&to=
|
||
```
|
||
- `#[IsGranted(new Expression("is_granted('ROLE_REPRESENTATION') or is_granted('ROLE_ADMIN')"))]`
|
||
- نمایندهی کاربر جاری را با `findByUser($user)` پیدا کن؛ اگر نبود → `403`.
|
||
- در Appointment Repository یک متد `findByRepresentation(int $representationId, array $filters)` اضافه کن که join بزند: `appointment.doctor d` و شرط `d.representationId = :repId`. خروجی با `$this->paginated($items, $total, $page, $limit)`.
|
||
- شکل هر آیتم همان `Appointment::toArray()` که ادمین مصرف میکند، تا `AppointmentsPage` بدون تغییر ساختاری آن را نشان دهد.
|
||
|
||
### ۴. Backend — endpoint نمایندهی کاربر جاری (برای داشبورد)
|
||
|
||
داشبورد فرانت به uuid نماینده نیاز دارد. یک endpoint بساز:
|
||
```
|
||
GET /api/v1/representation/me
|
||
```
|
||
- `#[IsGranted('ROLE_REPRESENTATION')]` (یا ادمینیانماینده)
|
||
- نمایندهی `#[CurrentUser]` را با `findByUser` برگردان (`$this->success(['data' => $rep->toArray()])`). اگر نبود → `404`.
|
||
|
||
### ۵. Admin SPA — مسیرها و گارد نقش
|
||
|
||
در `App.tsx`:
|
||
- مسیرهای `doctors` (لیست/افزودن) و `clinics` (لیست/افزودن) را به `RoleRoute roles={['admin','representation']}` تغییر بده (هر دو زیرمسیر فرم افزودن هم).
|
||
- `dashboard` و `appointments` (الان «همه نقشها») برای نماینده باز بمانند.
|
||
- هیچ مسیر admin-only دیگری (`users`, `payments`, `settlements`, `blogs`, `categories`, `sms`, `representations`, ...) را برای نماینده باز **نکن**.
|
||
|
||
### ۶. Admin SPA — منوی Sidebar برای نماینده
|
||
|
||
در `Sidebar.tsx` شاخهی `if (primaryRole === "representation") { ... }` با آیتمها:
|
||
- داشبورد (`/admin/dashboard`)
|
||
- پزشکان (`/admin/doctors`)
|
||
- کلینیکها (`/admin/clinics`)
|
||
- نوبتها (`/admin/appointments`)
|
||
|
||
از همان ساختار `Section`/`SectionItem` و آیکنهای موجود استفاده کن.
|
||
|
||
### ۷. Admin SPA — داشبورد نماینده
|
||
|
||
در `DashboardPage.tsx` بر اساس `primaryRole` شاخه بزن:
|
||
- اگر `representation`: اول `GET /api/v1/representation/me` را بگیر تا `uuid` نماینده را داشته باشی، سپس:
|
||
- `GET /api/v1/representation/{uuid}/dashboard/monthly`
|
||
- `GET /api/v1/representation/{uuid}/dashboard/yearly`
|
||
(شکل پاسخ در `docs/api/representation.md`: `total_appointments`، کمیسیون، و...)
|
||
- کارتها/نمودار را با همین دادهها بساز؛ از کارتهای admin-only (کاربران/پرداخت/تسویه) استفاده نکن.
|
||
- endpointهای `/api/v1/admin/dashboard/*` نباید برای نماینده صدا زده شوند (۴۰۳ میدهند).
|
||
|
||
### ۸. Admin SPA — صفحه نوبتها برای نماینده
|
||
|
||
در `AppointmentsPage.tsx`:
|
||
```ts
|
||
const isRepresentation = primaryRole === 'representation';
|
||
const apptEndpoint = isAdmin
|
||
? '/api/v1/admin/appointments'
|
||
: isRepresentation
|
||
? '/api/v1/representation/appointments'
|
||
: '/api/v1/my/appointments';
|
||
```
|
||
- بقیهی فیلترها (status/from/to/page/limit) همانطور پاس داده شوند.
|
||
|
||
### ۹. مستندسازی
|
||
|
||
- `docs/api/auth.md`: مقدار جدید `primary_role: "representation"` و دسترسی این نقش به پنل.
|
||
- `docs/api/admin.md`: کنار `createDoctor`/`createClinic` ذکر کن که نسخهی نماینده (`/api/v1/representation/doctor` و `/api/v1/representation/clinic`) هم وجود دارد و `representation_id` پزشک خودکار ست میشود.
|
||
- `docs/api/representation.md`: endpointهای جدید `GET /api/v1/representation/appointments`، `GET /api/v1/representation/me`، و `POST /api/v1/representation/doctor|clinic` با method/path/permission/query/response و نمونه JSON واقعی.
|
||
|
||
## نکات مهم
|
||
|
||
- **اصل امنیت:** نماینده فقط به دادهی خودش دسترسی دارد. در همهی endpointهای نماینده، نماینده را از `#[CurrentUser]` و `findByUser` پیدا کن — **هرگز** به `uuid`/`representationId` ورودیِ کلاینت برای تعیین مالکیت اعتماد نکن (الگوی موجود در `RepresentationController`: `$rep->getUser()->getId() !== $user->getId() && !hasRole('ROLE_ADMIN')` → 403).
|
||
- قانون سطح کلاس `#[IsGranted('ROLE_ADMIN')]` روی `AdminApiController` نباید ضعیف شود؛ ساخت پزشک/کلینیکِ نماینده را در controller جدا بگذار (وظیفهی ۲).
|
||
- `Doctor.representationId` ستون scalar است (نه association)؛ join/شرط در DQL مستقیم روی همین ستون.
|
||
- تاریخها Unix timestamp؛ لیستهای admin با `paginated()` (items از `data`, total از `meta.totalRecords`). در Admin SPA: paginated → `data?.data` / `data?.meta?.totalRecords`؛ single → `data?.data` (گاهی double-nested).
|
||
- اگر هیچ Entityی تغییر نکرد migration لازم نیست (این feature عمدتاً منطق/route/permission است). اگر برای مالکیت کلینیکِ نماینده فیلد جدیدی لازم شد، migration بساز و اجرا کن.
|
||
- بعد از تغییر هر controller، فایل docs مربوطه را همان session بهروز کن (قانون استاندارد پروژه).
|
||
- **تستها:** با توکن یک کاربر `ROLE_REPRESENTATION` (`ddev exec php bin/console lexik:jwt:generate-token <mobile> --user-class="App\\Auth\\Entity\\User"`) چک کن:
|
||
- `GET /oauth/userinfo` → `primary_role: "representation"`
|
||
- `POST /api/v1/representation/doctor` و `/clinic` → ۲۰۱ و `representation_id` ستشده
|
||
- `GET /api/v1/representation/appointments` → فقط نوبتهای پزشکانِ همان نماینده
|
||
- `GET /api/v1/representation/me` و dashboard ماهانه/سالانه → ۲۰۰
|
||
- `GET /api/v1/admin/users` با همان توکن → **۴۰۳** (نباید دسترسی داشته باشد)
|
||
- در Admin SPA با همان کاربر وارد شو: فقط ۴ آیتم منو دیده شود و افزودن پزشک/کلینیک کار کند.
|