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