diff --git a/.claude/prompt/fix-clinic-doctor-invitation-flow.md b/.claude/prompt/fix-clinic-doctor-invitation-flow.md new file mode 100644 index 00000000..a3ad6781 --- /dev/null +++ b/.claude/prompt/fix-clinic-doctor-invitation-flow.md @@ -0,0 +1,249 @@ +# اصلاح کامل فرآیند دعوت پزشک به کلینیک (Clinic Doctor Invitation) + +## پروژه + +`clinicpro` (backend Symfony + پنل ادمین React). مصرف‌کننده‌ای در `nobat724_front` ندارد — صفحه پذیرش دعوت‌نامه Twig سمت خود Symfony است (`/i/{token}`). + +## زمینه + +کلینیک «علی بهروزی» (موبایل مالک `09024206041`) از مسیر `/admin/settings/clinic-doctors` → «دعوت از پزشکان» یک دعوت‌نامه برای «دکتر تست» با موبایل `09100652121` ارسال کرده است. هیچ‌کدام از سه مرحلهٔ فرآیند درست کار نمی‌کند: + +1. بعد از ارسال دعوت‌نامه هیچ پروفایل پزشکی ساخته نمی‌شود. +2. بعد از باز کردن لینک تأیید و زدن «تأیید»، عملاً هیچ اتفاقی نمی‌افتد؛ پزشک نمی‌تواند با `09100652121` وارد شود و به کلینیک وصل نمی‌شود. +3. در صفحهٔ `/admin/settings/clinic-doctors` اکشن‌های دعوت‌نامه (ارسال مجدد، حذف، تعلیق) خطا می‌دهند. + +ریشهٔ همهٔ اینها مشخص شده است و در بخش «وضعیت فعلی» دقیقاً نقل شده. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/ClinicInvitation/Service/ClinicInvitationService.php` | منطق invite / accept / resend / changeStatus / delete | +| `src/ClinicInvitation/Controller/ClinicInvitationController.php` | endpointهای JSON `/api/v1/...` | +| `src/ClinicInvitation/Controller/ClinicInvitationWebController.php` | صفحات عمومی `/i/{token}` و `POST /clinic-invitation/{token}/respond` | +| `src/ClinicInvitation/Entity/ClinicDoctorInvitation.php` | Entity دعوت‌نامه (`clinic_doctor_invitations`) | +| `src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php` | `acceptedDoctorIdsByClinic()`، `findPendingByDoctor()` | +| `src/Clinic/Entity/Clinic.php:89-95` | رابطهٔ ManyToMany `clinic_doctors` (طرف owning همین Clinic است) | +| `src/Auth/Controller/PreRegistrationController.php:119-142` | الگوی مرجع ساخت User + Doctor + ارسال پسورد با SMS | +| `assets/admin/components/ClinicDoctorsManager.tsx` | UI لیست پزشکان/دعوت‌نامه‌ها و همهٔ اکشن‌ها | +| `assets/admin/components/ui/InviteDoctorModal.tsx` | فرم ارسال دعوت | +| `assets/admin/api.ts:74` | لایهٔ fetch — پارس پاسخ | +| `docs/api/clinic-invitation.md` | مستند API (باید هم‌راستا شود) | + +## وضعیت فعلی + +### الف) `accept()` هیچ کاربر/پزشکی نمی‌سازد + +`src/ClinicInvitation/Service/ClinicInvitationService.php:73-98` + +```php +public function accept(ClinicDoctorInvitation $inv): void +{ + if (!$inv->isUsable()) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410); + } + + $inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED); + $inv->markUsed(); + + $doctor = $inv->getDoctor(); + if ($doctor === null) { + $doctor = $this->doctorRepo->findOneByMobile($inv->getMobile()); + if ($doctor !== null) { + $inv->setDoctor($doctor); + } + } + + if ($doctor !== null) { + $clinic = $inv->getClinic(); + if (!$clinic->getDoctors()->contains($doctor)) { + $clinic->getDoctors()->add($doctor); + } + } + + $this->em->flush(); +} +``` + +`invite()` (`:24-44`) هم فقط پزشک موجود را با موبایل پیدا و attach می‌کند و چیزی نمی‌سازد. +`DoctorRepository::findOneByMobile()` (`src/Doctor/Repository/DoctorRepository.php:31-40`) روی `d.user` join می‌زند و `u.mobileNumber` را می‌سنجد — یعنی فقط پزشکی را پیدا می‌کند که از قبل هم `User` و هم `Doctor` دارد. + +**نتیجه:** برای شمارهٔ `09100652121` که کاربر ندارد، accept فقط وضعیت را `accepted` و توکن را `used` می‌کند؛ `doctor_id` همچنان `NULL` می‌ماند، سطر `clinic_doctors` ساخته نمی‌شود، پزشک لاگین ندارد، و چون `resend()` (`:48`) دعوت‌نامهٔ accepted را رد می‌کند، دعوت‌نامه غیرقابل‌بازیابی می‌شود. همچنین `acceptedDoctorIdsByClinic()` (`Repository:54`) با شرط `i.doctor IS NOT NULL` آن را نادیده می‌گیرد و `/api/v1/doctor/invitations` و `/respond` (`Controller:147, :172`) قبل از هر کاری با «پروفایل پزشک یافت نشد» **404** می‌دهند — این همان ۴۰۴ گزارش‌شده است. + +### ب) تغییر وضعیت (لغو تعلیق) رد می‌شود + +`ClinicDoctorsManager.tsx:235` مقدار `pending` می‌فرستد: + +```tsx +status: inv.status === 'suspended' ? 'pending' : 'suspended' +``` + +اما `ClinicInvitationService.php:59` فقط `['suspended','removed']` را می‌پذیرد و در غیر این‌صورت «وضعیت نامعتبر است» برمی‌گرداند. مستند `docs/api/clinic-invitation.md:176` هم `pending` را مجاز اعلام کرده — یعنی مستند و فرانت با هم موافق‌اند و کد مخالف است. + +### ج) حذف دعوت‌نامه با وجود موفقیت، خطا نشان می‌دهد + +`ClinicInvitationController.php:136` پاسخ می‌دهد `return $this->success(null, 204);` — Symfony بدنهٔ 204 را حذف می‌کند، ولی `assets/admin/api.ts:74` روی هر پاسخ ok بی‌قید `res.json()` صدا می‌زند → `SyntaxError` → `deleteInvMut.onError` در `ClinicDoctorsManager.tsx:97` اجرا می‌شود. + +### د) resend توکن را عوض می‌کند + +`ClinicInvitationService::resend()` → `$inv->refresh()` (`Entity:97`) توکن جدید می‌سازد و لینک SMS قبلی را بی‌سروصدا باطل می‌کند. + +### مسیرهای ثبت‌شده (تأییدشده با `debug:router`) + +``` +POST /api/v1/admin/clinic/{uuid}/invite-doctor +GET /api/v1/admin/clinic/{uuid}/invitations +POST /api/v1/admin/clinic/invitation/{invUuid}/resend +PATCH /api/v1/admin/clinic/invitation/{invUuid}/status +DELETE /api/v1/admin/clinic/invitation/{invUuid} +GET /api/v1/doctor/invitations (ROLE_DOCTOR) +POST /api/v1/doctor/invitation/{invUuid}/respond (ROLE_DOCTOR) +GET /api/v1/clinic-invitation/{token} (public) +POST /api/v1/clinic-invitation/{token}/accept (public) +POST /api/v1/clinic-invitation/{token}/reject (public) +GET /i/{token} | GET /clinic-invitation/{token} (Twig) +POST /clinic-invitation/{token}/respond (CSRF: invitation_{token}) +``` + +مسیرهایی که فرانت صدا می‌زند با اینها یکی است؛ **هیچ 404 مسیرمحوری وجود ندارد** — 404ها از نبودِ پروفایل پزشک می‌آیند. + +## وظایف + +### ۱. ساخت خودکار `User` + `Doctor` هنگام accept (اصلی‌ترین اصلاح) + +در `ClinicInvitationService` یک متد خصوصی `resolveOrCreateDoctor(ClinicDoctorInvitation $inv): Doctor` اضافه کن که: + +1. اگر `$inv->getDoctor()` موجود بود همان را برگرداند. +2. وگرنه با `doctorRepo->findOneByMobile($inv->getMobile())` جست‌وجو کند. +3. وگرنه `User` را با `userRepo->findOneBy(['mobileNumber' => $inv->getMobile()])` پیدا یا بسازد؛ اگر ساخت جدید بود، پسورد تصادفی تولید کند، با hasher هش کند و **حتماً با SMS برای پزشک بفرستد** (بدون این کار پزشک باز هم نمی‌تواند وارد شود). +4. `ROLE_DOCTOR` را به کاربر اضافه کند (`addRole` مثل `PreRegistrationController`). +5. اگر کاربر `Doctor` ندارد، `new Doctor($user, $name)` بسازد و `setMobileNumber()` را ست کند. نام از `$inv->getName()` (عنوان واردشده در دعوت، مثلاً «دکتر تست») و در نبودش از `$user->getRealName()` یا خود موبایل. + +الگوی مرجع — `src/Auth/Controller/PreRegistrationController.php:119-142`: + +```php +$password = bin2hex(random_bytes(4)); +$user = $this->userRepo->findOneBy(['mobileNumber' => $mobile]); +if (!$user) { $user = new User($mobile); } +$user->setPasswordHash($this->hasher->hashPassword($user, $password)); +$user->setRealName($name); +$this->em->persist($user); +... +$user->addRole('ROLE_DOCTOR'); +$doctor = $this->doctorRepo->findOneBy(['user' => $user]); +if (!$doctor) { + $doctor = new Doctor($user, $name); + $doctor->setMobileNumber($mobile); + $this->em->persist($doctor); +} +``` + +سپس `accept()` را بازنویسی کن: + +```php +public function accept(ClinicDoctorInvitation $inv): void +{ + if (!$inv->isUsable()) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410); + } + + $doctor = $this->resolveOrCreateDoctor($inv); // هرگز null برنمی‌گرداند + $inv->setDoctor($doctor); + + $clinic = $inv->getClinic(); + if (!$clinic->getDoctors()->contains($doctor)) { + $clinic->getDoctors()->add($doctor); + } + + $inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED); + $inv->markUsed(); + + $this->em->flush(); +} +``` + +نکات پیاده‌سازی: +- کل accept باید داخل یک transaction باشد (`$this->em->wrapInTransaction(...)`) — نباید حالتی پیش بیاید که دعوت‌نامه `used` شود ولی کاربر ساخته نشود. +- `Doctor` فقط دو فیلد اجباری دارد: `user` و `name` (هر دو آرگومان constructor، `src/Doctor/Entity/Doctor.php:143`)؛ `new Doctor($user, $name)` به‌تنهایی persist‌شدنی است. +- برای لاگین با پسورد، `User::isStaff()` (`src/User/Entity/User.php:130`) لازم است — `ROLE_DOCTOR` این شرط را برآورده می‌کند. +- ارسال SMS پسورد را با همان سرویس SMS و الگوی `dispatchTemplate` انجام بده؛ اگر تمپلیت اختصاصی دعوت وجود ندارد، تمپلیت جدید اضافه کن (از `SmsLog::TAG_PRE_REGISTRATION` الگو بگیر) و متن آن نام کلینیک را هم داشته باشد. +- اگر کاربر از قبل وجود دارد (پسورد دارد)، **پسورد را بازنویسی نکن** — فقط نقش و پروفایل را کامل کن و SMS اطلاع‌رسانی «به کلینیک X متصل شدید» بفرست. + +### ۲. ساخت پروفایل پزشک هنگام ارسال دعوت (اختیاری ولی خواسته‌شدهٔ کاربر) + +کاربر انتظار دارد بلافاصله پس از ارسال دعوت، پروفایل پزشک وجود داشته باشد. در `invite()` هم همان `resolveOrCreateDoctor()` را صدا بزن، اما: + +- در این حالت **پسورد ارسال نکن** و کاربر را در وضعیت «معلق تا تأیید» نگه‌دار — پیشنهاد: `Doctor::setOwnerStatus('unclaimed')` تا زمانی که دعوت accept شود، و در accept به `'claimed'` تغییر کند. +- **مهم:** پزشکِ تازه‌ساخته‌شده نباید قبل از accept به `clinic_doctors` اضافه شود؛ افزودن به کلینیک فقط در accept. +- اگر تصمیم گرفتی این کار را نکنی (به‌دلیل ریسک ساخت کاربر ناخواسته)، در پاسخ به کاربر صریح توضیح بده و در `docs/api/clinic-invitation.md` مستند کن که پروفایل در لحظهٔ accept ساخته می‌شود. + +### ۳. اصلاح `changeStatus` برای پذیرش `pending` + +`ClinicInvitationService.php:59` — `pending` را به وایت‌لیست اضافه کن: + +```php +$allowed = [ + ClinicDoctorInvitation::STATUS_PENDING, + ClinicDoctorInvitation::STATUS_SUSPENDED, + ClinicDoctorInvitation::STATUS_REMOVED, +]; +``` + +مراقب باش: برگشت به `pending` باید دعوت‌نامه را واقعاً قابل‌استفاده کند — اگر `token_used` یا انقضا مانع است، هنگام برگشت به `pending` توکن را refresh کن و SMS دوباره بفرست، یا اگر منطق کسب‌وکار اجازه نمی‌دهد، دکمهٔ لغو تعلیق را در UI برای حالت‌های غیرمجاز غیرفعال کن. حالت انتخابی را در مستند بنویس. + +### ۴. اصلاح پاسخ حذف (رفع toast خطای کاذب) + +دو راه؛ **راه اول ارجح** است: + +- `ClinicInvitationController.php:136` را از `$this->success(null, 204)` به `$this->success(null)` (یعنی 200 با بدنهٔ `{success: true, data: null}`) تغییر بده تا با envelope استاندارد `BaseController` سازگار شود، و `docs/api/clinic-invitation.md` را به‌روز کن. +- یا در `assets/admin/api.ts:74` قبل از `res.json()` شرط `if (res.status === 204) return null;` بگذار. + +پس از تغییر، سایر endpointهایی که 204 برمی‌گردانند را هم بررسی کن تا همین باگ جای دیگری تکرار نشود. + +### ۵. بازبینی کامل همهٔ اکشن‌های صفحهٔ `/admin/settings/clinic-doctors` + +هر شش فراخوانی `ClinicDoctorsManager.tsx` را عملاً تست کن و مطمئن شو خطا نمی‌دهند: + +| خط | فراخوانی | +|---|---| +| `:64` | `GET /api/v1/clinic/doctor-list/${clinicUuid}` | +| `:70` | `GET /api/v1/admin/clinic/${clinicUuid}/invitations?limit=50` | +| `:82` | `POST /api/v1/admin/clinic/invitation/${invUuid}/resend` | +| `:89` | `PATCH /api/v1/admin/clinic/invitation/${invUuid}/status` | +| `:95` | `DELETE /api/v1/admin/clinic/invitation/${invUuid}` | +| `:102` | `DELETE /api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}` | + +به‌علاوه `InviteDoctorModal.tsx:33` → `POST /api/v1/admin/clinic/${clinicUuid}/invite-doctor`. + +نکات: +- بررسی کن `clinicUuid` که `ClinicDoctorsPage.tsx` پاس می‌دهد (`dbUuid`) همان uuid‌ای است که کنترلر انتظار دارد — اگر uuid کاربر به‌جای uuid کلینیک برود، همهٔ این مسیرها 404 می‌دهند. این را با کلینیک واقعی «علی بهروزی» تست کن. +- خطاها باید پیام فارسی معنادار نشان دهند، نه toast عمومی. +- در `resend`، به کاربر هشدار بده که لینک قبلی باطل می‌شود (`Entity:97` توکن جدید می‌سازد). +- `STATUS_REMOVED` (soft delete) از UI اصلاً قابل‌دسترسی نیست چون فرانت همیشه hard delete می‌زند — یا از UI قابل دسترس کن یا حذف کن؛ حالت مرده نگه ندار. + +### ۶. بازیابی دعوت‌نامه‌های خراب‌شدهٔ موجود + +یک migration یا console command بنویس که دعوت‌نامه‌های `status = accepted` با `doctor_id IS NULL` را پیدا کند و برایشان User+Doctor بسازد و به کلینیک وصل کند (همان `resolveOrCreateDoctor`). دعوت‌نامهٔ `09100652121` در کلینیک «علی بهروزی» دقیقاً همین حالت را دارد. + +### ۷. تست انتها‌به‌انتها + +با کلینیک «علی بهروزی» (`09024206041`) این سناریو را کامل اجرا کن: + +1. دعوت پزشک جدید با موبایل تستی. +2. باز کردن `/i/{token}` و زدن «تأیید». +3. بررسی در DB: `users` سطر جدید با `ROLE_DOCTOR`، `doctors` سطر جدید، `clinic_doctors` سطر پیوند، `clinic_doctor_invitations.doctor_id` پرشده. +4. لاگین با آن موبایل و پسورد SMS‌شده از `POST /api/v1/user/login` (یا OTP: در dev کد همیشه `12345`). +5. فراخوانی `GET /api/v1/doctor/invitations` با توکن پزشک — نباید 404 بدهد. +6. بازگشت به `/admin/settings/clinic-doctors` — پزشک باید در لیست پزشکان کلینیک دیده شود. + +## نکات مهم + +- همهٔ controllerها از `BaseController` ارث می‌برند؛ پاسخ‌ها فقط با `$this->success()` / `$this->paginated()` / `$this->error()`. +- `ClinicDoctor` entity وجود ندارد — پیوند یک ManyToMany یک‌طرفه است که owning side آن `Clinic` است (`src/Clinic/Entity/Clinic.php:89-95`)، پس `$clinic->getDoctors()->add($doctor)` درست persist می‌شود ولی عکسش نه. +- مسیرهای عمومی `^/api/v1/clinic-invitation/` در `config/packages/security.yaml:36` و `:95` whitelist شده‌اند؛ اگر endpoint عمومی جدیدی اضافه کردی، آن‌جا هم ثبتش کن. +- فرم Twig در `POST /clinic-invitation/{token}/respond` توکن CSRF با شناسهٔ `invitation_{token}` دارد — اگر فرم را تغییر دادی این را نگه‌دار. +- تاریخ‌ها Unix timestamp صحیح، نمایش شمسی با `formatDate()`. +- در پنل ادمین: لیست‌های paginated → `data?.data` و `data?.meta?.totalRecords`؛ تک‌آیتم → `data?.data`. +- اگر Entity تغییر کرد (مثلاً فیلد جدید روی دعوت‌نامه)، migration بساز. +- **پس از هر تغییر API، `docs/api/clinic-invitation.md` باید در همین session به‌روز شود** (به‌ویژه وایت‌لیست `status` و کد وضعیت حذف). +- `graphify update .` بعد از commit. diff --git a/assets/admin/components/ClinicDoctorsManager.tsx b/assets/admin/components/ClinicDoctorsManager.tsx index 9bf37243..9db74c4c 100644 --- a/assets/admin/components/ClinicDoctorsManager.tsx +++ b/assets/admin/components/ClinicDoctorsManager.tsx @@ -87,7 +87,10 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: { const changeInvStatusMut = useMutation({ mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) => api.patch>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }), - onSuccess: () => { toast.success('وضعیت دعوتنامه تغییر کرد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); }, + onSuccess: (_d, v) => { + toast.success(v.status === 'pending' ? 'دعوتنامه فعال و پیامک مجدداً ارسال شد' : 'دعوتنامه تعلیق شد'); + qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); + }, onError: (e: Error) => toast.error(e.message), }); @@ -230,7 +233,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: { {inv.status !== 'removed' && inv.status !== 'accepted' && (