fix(clinic-invitation): provision doctor accounts and repair panel actions
The invitation flow never created an account for the invitee. accept() only looked up an existing doctor by mobile, so for a brand-new invitee it marked the invitation accepted and burned the token while leaving doctor_id NULL — no login, no clinic link, and every doctor-facing endpoint 404ing afterwards. - invite/accept now provision the users + doctors pair, claim the profile on accept, link it to the clinic, and SMS generated credentials when the user has no password. Existing passwords are never overwritten. - accept runs in one transaction so an invitation can no longer be marked accepted without its doctor profile and clinic link. - changeStatus accepts `pending`, refreshing the token and re-sending the SMS so reactivating a suspended invitation yields a link that actually works. Answered invitations are rejected with 409. - DELETE returns 200 with the standard envelope instead of a bodyless 204, which made the admin panel show a false error toast; api.ts also stops calling res.json() on empty responses. - The clinic-doctors settings page sent the active context uuid as the clinic uuid, so users holding both a doctor and a clinic context got 404 on every invitation action. It now always resolves the clinic context. - Adds app:invitations:repair to fix invitations already left orphaned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user