# اصلاح کامل فرآیند دعوت پزشک به کلینیک (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.