diff --git a/.claude/prompt/clinic-view-doctor-profile.md b/.claude/prompt/clinic-view-doctor-profile.md new file mode 100644 index 00000000..2eed79dc --- /dev/null +++ b/.claude/prompt/clinic-view-doctor-profile.md @@ -0,0 +1,147 @@ +# دسترسی صاحب کلینیک به پروفایل پزشکان عضو کلینیک + +## زمینه + +وقتی صاحب کلینیک (`ROLE_CLINIC`) در صفحه کلینیک روی آیکون چشم کنار نام یک پزشک کلیک می‌کند، به جای مشاهده پروفایل پزشک، به داشبورد redirect می‌شود. دلیل: route `doctors/:uuid` در `App.tsx` فقط `['admin', 'doctor']` را قبول دارد و `RoleRoute` صاحب کلینیک را به `/admin/dashboard` می‌فرستد. + +## مشکل + +دو لایه مشکل وجود دارد: + +**۱. Frontend — Route permission:** +```tsx +// assets/admin/App.tsx — خط 141 +} /> +``` +`RoleRoute` فقط `primaryRole` را چک می‌کند — اگر `clinic` بود، redirect به dashboard می‌کند. + +**۲. Backend — API permission:** +```php +// src/Doctor/Controller/DoctorController.php — خط 140-141 +#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])] +public function show(string $uuid): JsonResponse // بدون IsGranted — PUBLIC است +``` +`GET /api/v1/doctor/{uuid}` عمومی است، اما برای اطمینان از امنیت باید یک endpoint اختصاصی برای کلینیک وجود داشته باشد که تأیید کند پزشک واقعاً عضو کلینیک مربوطه است. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `assets/admin/App.tsx` | تعریف route و RoleRoute — جای اصلی مشکل frontend | +| `assets/admin/pages/DoctorDetailPage.tsx` | صفحه پروفایل پزشک | +| `src/Doctor/Controller/DoctorController.php` | `GET /api/v1/doctor/{uuid}` | +| `src/Clinic/Repository/ClinicRepository.php` | دارای `findByDoctor()` و `findByUser()` | + +## وضعیت فعلی + +```tsx +// App.tsx خط 141 +} /> + +// RoleRoute خط 70-74 +function RoleRoute({ roles, children }: { roles: string[]; children: React.ReactNode }) { + const primaryRole = useAuthStore((s) => s.primaryRole); + if (!primaryRole) return
در حال بارگذاری...
; + if (!roles.includes(primaryRole)) return ; + return <>{children}; +} +``` + +```php +// DoctorController.php خط 140-163 — PUBLIC, no auth check +#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])] +public function show(string $uuid): JsonResponse +{ + $doctor = $this->doctorRepo->findByUuid($uuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + // ... بدون چک مالکیت یا عضویت در کلینیک + return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => $clinicData])]); +} +``` + +## وظایف + +### ۱. Frontend — اضافه کردن `clinic` به RoleRoute مسیر پزشک + +```tsx +// App.tsx — خط 141 را تغییر بده +} +/> +``` + +### ۲. Backend — endpoint اختصاصی برای کلینیک + +یک endpoint جدید در `DoctorController` اضافه کن که صاحب کلینیک بتواند پروفایل پزشکان عضو کلینیکش را ببیند، با تأیید عضویت: + +```php +// src/Doctor/Controller/DoctorController.php +#[Route('/api/v1/clinic/my-doctor/{doctorUuid}', methods: ['GET'])] +#[IsGranted('ROLE_CLINIC')] +public function showForClinic(string $doctorUuid, #[CurrentUser] User $user): JsonResponse +{ + $clinic = $this->clinicRepo->findByUser($user); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404); + } + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404); + } + + // فقط پزشکانی که عضو این کلینیک هستند قابل مشاهده‌اند + if (!$clinic->getDoctors()->contains($doctor)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'این پزشک عضو کلینیک شما نیست', 403); + } + + $clinicData = [[ + 'id' => (string) $clinic->getId(), + 'uuid' => $clinic->getUuid(), + 'name' => $clinic->getName(), + 'address' => $clinic->getAddress(), + 'telephone' => $clinic->getTelephone(), + ]]; + + return $this->success(['data' => array_merge($doctor->toDetailArray(), ['clinics' => $clinicData])]); +} +``` + +**وابستگی‌های constructor**: `ClinicRepository $clinicRepo` باید به `DoctorController` اضافه شود (بررسی کن آیا از قبل موجود است). + +### ۳. Frontend — DoctorDetailPage: fetch با endpoint مناسب + +در `DoctorDetailPage.tsx`، وقتی `primaryRole === 'clinic'`، از endpoint جدید استفاده کن: + +```tsx +// در DoctorDetailPage.tsx — در useQuery که doctor را fetch می‌کند +const { data: doctorData } = useQuery({ + queryKey: ['doctor', uuid, primaryRole], + queryFn: () => + primaryRole === 'clinic' + ? api.get(`/api/v1/clinic/my-doctor/${uuid}`) + : api.get(`/api/v1/doctor/${uuid}`), + enabled: !!uuid, +}); +``` + +### ۴. Frontend — DoctorDetailPage: محدود کردن قابلیت‌های ویرایش برای clinic + +وقتی `primaryRole === 'clinic'`: +- دکمه ویرایش پروفایل نمایش داده **نشود** (فقط مشاهده) +- دکمه حذف نمایش داده **نشود** +- بخش دعوتنامه‌ها (`ClinicInvitationsSection`) نمایش داده **نشود** + +بررسی کن چه بخش‌هایی با `isOwnProfile` یا `primaryRole === 'admin'` guard شده‌اند و `clinic` را به عنوان read-only viewer handle کن. + +## نکات مهم + +- `ClinicRepository::findByUser(User $user)` وجود دارد — از آن برای پیدا کردن کلینیک صاحب استفاده کن +- `Clinic::getDoctors()` یک `Collection` است — از `->contains($doctor)` برای چک عضویت استفاده کن +- `GET /api/v1/doctor/{uuid}` را تغییر نده — این endpoint عمومی است و frontend برای admin/doctor از آن استفاده می‌کند +- migration لازم نیست — هیچ entity تغییر نمی‌کند +- بعد از تغییر route باید `ddev exec php bin/console cache:clear` اجرا شود +- مستندات: `docs/api/doctor.md` را با endpoint جدید به‌روزرسانی کن diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 4bf6c640..05daa030 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -138,7 +138,7 @@ export default function App() { {/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه می‌کند */} } /> } /> - } /> + } /> } /> {/* دکتر / منشی / کلینیک */} diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index d9a39c1e..8f6b0edb 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -358,24 +358,31 @@ async function uploadDoctorImage(file: File): Promise { function DoctorAvatar({ name, img, idx, onUpload, uploading }: { name: string; img: string | null; idx: number; - onUpload: (f: File) => void; uploading: boolean; + onUpload?: (f: File) => void; uploading: boolean; }) { const ref = useRef(null); const initials = name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr'; return ( -
ref.current?.click()}> +
onUpload && ref.current?.click()} + > {img ? {name} :
{initials}
} -
- {uploading - ? - : - } -
- { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; }} /> + {onUpload && ( +
+ {uploading + ? + : + } +
+ )} + {onUpload && ( + { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; }} /> + )}
); } @@ -2148,8 +2155,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil // ── Queries ── const { data, isLoading, isError } = useQuery({ - queryKey: ['doctor-detail', uuid], - queryFn: () => api.get>(`/api/v1/doctor/${uuid}`), + queryKey: ['doctor-detail', uuid, primaryRole], + queryFn: () => + primaryRole === 'clinic' + ? api.get>(`/api/v1/clinic/my-doctor/${uuid}`) + : api.get>(`/api/v1/doctor/${uuid}`), enabled: !!uuid, }); const specialtiesQ = useQuery({ @@ -2332,9 +2342,9 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil ) : ( <> - / پروفایل پزشک @@ -2349,8 +2359,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
- کلیک برای تغییر عکس + onUpload={primaryRole !== 'clinic' ? handleImageUpload : undefined} uploading={uploadingImg} /> + {primaryRole !== 'clinic' && ( + کلیک برای تغییر عکس + )}
@@ -2389,12 +2401,14 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
- + {primaryRole !== 'clinic' && ( + + )} {/* Toggle active / Delete — فقط ادمین */} - {!isOwnProfile && ( + {!isOwnProfile && primaryRole !== 'clinic' && ( <>