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.
|
||||
@@ -87,7 +87,10 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
|
||||
const changeInvStatusMut = useMutation({
|
||||
mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
|
||||
api.patch<ApiResponse<any>>(`/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' && (
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="تعلیق"
|
||||
title={inv.status === 'suspended' ? 'فعالسازی و ارسال مجدد پیامک' : 'تعلیق'}
|
||||
disabled={changeInvStatusMut.isPending}
|
||||
onClick={() => changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
|
||||
>
|
||||
|
||||
@@ -71,6 +71,11 @@ async function request<T>(
|
||||
);
|
||||
}
|
||||
|
||||
// پاسخ بدون بدنه (۲۰۴ یا Content-Length صفر) نباید به res.json() برسد
|
||||
if (res.status === 204 || res.headers.get('Content-Length') === '0') {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
@@ -13,16 +13,25 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
|
||||
* detail page, reachable via the "ویرایش اطلاعات کلینیک" link.
|
||||
*/
|
||||
function ClinicDoctorsContent() {
|
||||
const { dbUuid, fetchMe } = useAuthStore();
|
||||
const { dbUuid, context, availableContexts, fetchMe } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!dbUuid) fetchMe();
|
||||
}, [dbUuid, fetchMe]);
|
||||
|
||||
if (!dbUuid) {
|
||||
// یک کاربر میتواند همزمان context پزشک و کلینیک داشته باشد؛ dbUuid فقط context فعال است.
|
||||
// این صفحه همیشه باید uuid کلینیک را بفرستد، وگرنه همه اندپوینتهای کلینیک ۴۰۴ میدهند.
|
||||
const clinicUuid = useMemo(() => {
|
||||
if (context?.type === 'clinic') return dbUuid;
|
||||
return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null;
|
||||
}, [context, dbUuid, availableContexts]);
|
||||
|
||||
if (!clinicUuid) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
|
||||
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>
|
||||
{dbUuid ? 'کلینیکی برای این حساب کاربری یافت نشد' : 'در حال بارگذاری اطلاعات کلینیک...'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,12 +43,12 @@ function ClinicDoctorsContent() {
|
||||
<h1 className="section-title">پزشکان کلینیک</h1>
|
||||
<div className="muted">مدیریت پزشکان و دعوتنامههای کلینیک</div>
|
||||
</div>
|
||||
<Link className="btn ghost sm" to={`/admin/clinics/${dbUuid}`}>
|
||||
<Link className="btn ghost sm" to={`/admin/clinics/${clinicUuid}`}>
|
||||
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش اطلاعات کلینیک
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ClinicDoctorsManager clinicUuid={dbUuid} />
|
||||
<ClinicDoctorsManager clinicUuid={clinicUuid} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
|
||||
Admins invite doctors to clinics via SMS. The doctor receives a short (12-char hex) token link valid for 72 hours. Tapping the link opens a server-rendered HTML page (Twig) where the doctor accepts or rejects the invitation (see **Web pages** at the bottom).
|
||||
|
||||
### Account provisioning
|
||||
|
||||
The invitation flow creates the invitee's account for them — no prior registration is required:
|
||||
|
||||
| Stage | `users` row | `doctors` row | Password | `clinic_doctors` link |
|
||||
|---|---|---|---|---|
|
||||
| **Invite** | created if missing, gains `ROLE_DOCTOR` | created if missing, `owner_status = unclaimed` | not set | ❌ |
|
||||
| **Accept** | reused | `owner_status → claimed`, `claimed_at` set | generated **only if the user has none**, then SMS'd | ✅ |
|
||||
|
||||
Accept runs inside a single transaction, so an invitation is never marked `accepted` without its doctor profile and clinic link. An existing user's password is **never** overwritten — someone who already has an account simply gets linked to the clinic.
|
||||
|
||||
Credentials are sent with the `pre_registration` SMS template: `به کلینیک پرو خوش آمدید! شمارهکاربری: {username} | رمز عبور: {password} | لینک ورود: {link}`.
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/admin/clinic/{uuid}/invite-doctor`
|
||||
@@ -45,11 +58,14 @@ Send an invitation to a doctor (by mobile number) to join a clinic.
|
||||
"invited_at": 1717000000,
|
||||
"expires_at": 1717259200,
|
||||
"token_used": false,
|
||||
"doctor": { "uuid": "...", "name": "دکتر علی احمدی" },
|
||||
"clinic": { "uuid": "...", "name": "کلینیک الوند" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `doctor` is always populated — the profile is provisioned at invite time (see **Account provisioning**), so the invitee is visible in the panel before they respond.
|
||||
|
||||
> SMS is dispatched **asynchronously** via Symfony Messenger → Redis queue.
|
||||
> SMS text: `"دکتر گرامی، کلینیک {name} شما را برای همکاری دعوت کرده است.\nبرای بررسی: {link}\nاین لینک تا ۷۲ ساعت معتبر است."`
|
||||
> `{link}` = `{APP_BASE_URL}/i/{token}` — short path + 12-char token to keep the SMS small (a long URL caused Kavenegar `431`).
|
||||
@@ -175,6 +191,8 @@ Change the status of an invitation (e.g., suspend or remove).
|
||||
|-------|------|----------|----------------|
|
||||
| `status` | string | ✅ | `pending`, `suspended`, `removed` |
|
||||
|
||||
> Setting `status: "pending"` **reactivates** a suspended invitation: the token is refreshed (the old link stops working) and the invitation SMS is re-sent, so the doctor gets a link that actually works. An invitation that was already `accepted` or `rejected` cannot be returned to `pending`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
@@ -189,6 +207,7 @@ Change the status of an invitation (e.g., suspend or remove).
|
||||
| `ERR_AUTH_001` | 401 | Missing token |
|
||||
| `ERR_AUTH_006` | 403 | Not admin |
|
||||
| `ERR_NOT_FOUND_001` | 404 | Invitation not found |
|
||||
| `ERR_CONFLICT_001` | 409 | Cannot return an already-answered invitation to `pending` |
|
||||
| `ERR_VALIDATION_001` | 422 | Invalid status value |
|
||||
|
||||
---
|
||||
@@ -204,8 +223,15 @@ Delete an invitation.
|
||||
|-------|------|-------------|
|
||||
| `invUuid` | string (UUID) | Invitation UUID |
|
||||
|
||||
### Response `204`
|
||||
Empty body.
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "message": "دعوتنامه حذف شد" }
|
||||
}
|
||||
```
|
||||
|
||||
> Hard delete. Returns `200` with the standard envelope rather than a bodyless `204`, so clients can parse every successful response the same way.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
@@ -264,14 +290,14 @@ View invitation details by token (used on the doctor-facing landing page).
|
||||
|
||||
Doctor accepts the invitation via SMS link.
|
||||
|
||||
**Side-effect:** If a doctor profile exists for this mobile, they are added to `clinic_doctors`. If the invitation's doctor FK was null (doctor registered after invite), the match is resolved at accept time using the mobile number.
|
||||
**Side-effects (single transaction):** resolves — or creates — the `users` + `doctors` pair for the invited mobile, marks the profile `claimed`, adds it to `clinic_doctors`, and sets the invitation to `accepted`. If the user had no password, one is generated and SMS'd so they can log in immediately. See **Account provisioning** at the top.
|
||||
|
||||
**Permission:** `PUBLIC`
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `token` | string | 96-char hex token |
|
||||
| `token` | string | 12-char hex token |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
@@ -357,7 +383,7 @@ Doctor accepts or rejects an invitation from their panel (no SMS token needed).
|
||||
|
||||
**Permission:** `ROLE_DOCTOR`
|
||||
|
||||
**Side-effect on accept:** Doctor is added to `clinic_doctors`. If doctor FK was null at invite time, it is resolved via mobile number at respond time.
|
||||
**Side-effect on accept:** identical to the public accept endpoint — the doctor profile is claimed and linked to `clinic_doctors` in one transaction.
|
||||
|
||||
### Path Parameters
|
||||
| Param | Type | Description |
|
||||
@@ -421,4 +447,15 @@ Processes the doctor's choice. **POST only** — accept/reject never happens on
|
||||
| Unknown `action` | `422` | `expired` page |
|
||||
| Token not found | `404` | `notfound` page |
|
||||
|
||||
> `accept` links the doctor to the clinic **only if** a doctor account exists for the invitation mobile (`accept()` looks it up by mobile). If none exists, the invitation is marked accepted but the doctor must still have/create an account to actually log in.
|
||||
> `accept` always ends with a usable account: the `users` + `doctors` pair is created when missing, the doctor is linked to the clinic, and login credentials are SMS'd if the user had no password (see **Account provisioning**).
|
||||
|
||||
---
|
||||
|
||||
## Console: `app:invitations:repair`
|
||||
|
||||
Repairs invitations left `accepted` with a null `doctor_id` by the pre-fix `accept()` — creates the missing user/doctor and links them to the clinic without touching the invitation's status.
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:invitations:repair --dry-run # report only
|
||||
ddev exec php bin/console app:invitations:repair # apply
|
||||
```
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicInvitation\Command;
|
||||
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use App\ClinicInvitation\Service\ClinicInvitationService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* دعوتنامههای پذیرفتهشدهای که قبل از رفع باگ بدون پروفایل پزشک ماندهاند را ترمیم میکند:
|
||||
* کاربر و پروفایل پزشک را میسازد و پزشک را به کلینیک متصل میکند.
|
||||
*/
|
||||
#[AsCommand(name: 'app:invitations:repair', description: 'ترمیم دعوتنامههای پذیرفتهشده بدون پروفایل پزشک')]
|
||||
class RepairAcceptedInvitationsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicDoctorInvitationRepository $invRepo,
|
||||
private readonly ClinicInvitationService $invitationService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی را تغییر نده');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
$orphans = $this->invRepo->createQueryBuilder('i')
|
||||
->where('i.status = :status')
|
||||
->andWhere('i.doctor IS NULL')
|
||||
->setParameter('status', ClinicDoctorInvitation::STATUS_ACCEPTED)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if ($orphans === []) {
|
||||
$io->success('دعوتنامهی ناقصی یافت نشد.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('%d دعوتنامه ناقص یافت شد.', count($orphans)));
|
||||
|
||||
$repaired = 0;
|
||||
foreach ($orphans as $inv) {
|
||||
$io->writeln(sprintf(
|
||||
' - %s (%s) → کلینیک %s',
|
||||
$inv->getMobile(),
|
||||
$inv->getInvitedName() ?? '—',
|
||||
$inv->getClinic()->getName() ?? $inv->getClinic()->getUuid(),
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->invitationService->repairAccepted($inv);
|
||||
$repaired++;
|
||||
}
|
||||
|
||||
$io->success($dryRun ? 'حالت آزمایشی — چیزی تغییر نکرد.' : sprintf('%d دعوتنامه ترمیم شد.', $repaired));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class ClinicInvitationController extends BaseController
|
||||
$this->assertClinicAccess($inv->getClinic(), $user);
|
||||
$this->invitationService->delete($inv);
|
||||
|
||||
return $this->success(null, 204);
|
||||
return $this->success(['message' => 'دعوتنامه حذف شد']);
|
||||
}
|
||||
|
||||
// ── Doctor-facing endpoints ──────────────────────────────────────────────
|
||||
|
||||
@@ -3,19 +3,24 @@
|
||||
namespace App\ClinicInvitation\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Sms\Service\SmsService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
class ClinicInvitationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicDoctorInvitationRepository $repo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly string $appUrl,
|
||||
@@ -32,10 +37,9 @@ class ClinicInvitationService
|
||||
$inv->setInvitedName($name);
|
||||
$inv->setInvitedSpecialty($specialty);
|
||||
|
||||
$doctor = $this->doctorRepo->findOneByMobile($mobile);
|
||||
if ($doctor !== null) {
|
||||
$inv->setDoctor($doctor);
|
||||
}
|
||||
// پروفایل پزشک همینجا ساخته میشود تا بلافاصله پس از دعوت قابل مشاهده باشد،
|
||||
// ولی بدون رمز عبور و بدون اتصال به کلینیک — اتصال فقط پس از پذیرش انجام میشود.
|
||||
$inv->setDoctor($this->provisionDoctor($inv));
|
||||
|
||||
$this->repo->save($inv);
|
||||
$this->sendSms($inv, $clinic);
|
||||
@@ -56,10 +60,28 @@ class ClinicInvitationService
|
||||
|
||||
public function changeStatus(ClinicDoctorInvitation $inv, string $status): void
|
||||
{
|
||||
$allowed = [ClinicDoctorInvitation::STATUS_SUSPENDED, ClinicDoctorInvitation::STATUS_REMOVED];
|
||||
$allowed = [
|
||||
ClinicDoctorInvitation::STATUS_PENDING,
|
||||
ClinicDoctorInvitation::STATUS_SUSPENDED,
|
||||
ClinicDoctorInvitation::STATUS_REMOVED,
|
||||
];
|
||||
if (!in_array($status, $allowed, true)) {
|
||||
throw new AppException('ERR_VALIDATION_001', 'وضعیت نامعتبر است', 422);
|
||||
}
|
||||
|
||||
// بازگشت به «در انتظار» فقط وقتی معنا دارد که لینک هم دوباره قابل استفاده شود،
|
||||
// پس توکن تازه میشود و پیامک مجدداً ارسال میگردد.
|
||||
if ($status === ClinicDoctorInvitation::STATUS_PENDING) {
|
||||
if (in_array($inv->getStatus(), [ClinicDoctorInvitation::STATUS_ACCEPTED, ClinicDoctorInvitation::STATUS_REJECTED], true)) {
|
||||
throw new AppException('ERR_CONFLICT_001', 'دعوتنامه پاسخدادهشده را نمیتوان به حالت انتظار برگرداند', 409);
|
||||
}
|
||||
$inv->refresh();
|
||||
$this->em->flush();
|
||||
$this->sendSms($inv, $inv->getClinic());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$inv->setStatus($status);
|
||||
$this->em->flush();
|
||||
}
|
||||
@@ -76,25 +98,106 @@ class ClinicInvitationService
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
|
||||
}
|
||||
|
||||
$password = $this->em->wrapInTransaction(function () use ($inv): ?string {
|
||||
$doctor = $this->attachDoctorToClinic($inv);
|
||||
|
||||
$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);
|
||||
return $this->ensureLoginCredentials($doctor->getUser());
|
||||
});
|
||||
|
||||
if ($password !== null) {
|
||||
$this->sendCredentialsSms($inv->getMobile(), $password);
|
||||
}
|
||||
}
|
||||
|
||||
if ($doctor !== null) {
|
||||
/**
|
||||
* ترمیم دعوتنامهای که قبلاً «پذیرفتهشده» ثبت شده ولی پروفایل پزشک برایش ساخته نشده است.
|
||||
* برخلاف accept()، وضعیت دعوتنامه را دست نمیزند.
|
||||
*/
|
||||
public function repairAccepted(ClinicDoctorInvitation $inv): void
|
||||
{
|
||||
$password = $this->em->wrapInTransaction(function () use ($inv): ?string {
|
||||
$doctor = $this->attachDoctorToClinic($inv);
|
||||
|
||||
return $this->ensureLoginCredentials($doctor->getUser());
|
||||
});
|
||||
|
||||
if ($password !== null) {
|
||||
$this->sendCredentialsSms($inv->getMobile(), $password);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* پروفایل پزشکِ دعوتنامه را قطعی میکند (در صورت نبود میسازد) و به کلینیک متصل میکند.
|
||||
*/
|
||||
private function attachDoctorToClinic(ClinicDoctorInvitation $inv): Doctor
|
||||
{
|
||||
$doctor = $this->provisionDoctor($inv);
|
||||
$inv->setDoctor($doctor);
|
||||
|
||||
if ($doctor->getOwnerStatus() !== 'claimed') {
|
||||
$doctor->transferOwnershipTo($doctor->getUser());
|
||||
}
|
||||
|
||||
$clinic = $inv->getClinic();
|
||||
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
}
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
/**
|
||||
* پروفایل پزشک متناظر با شماره موبایل دعوتنامه را برمیگرداند و در صورت نبود
|
||||
* کاربر و پروفایل را میسازد. رمز عبور اینجا ست نمیشود — آن کار فقط هنگام پذیرش.
|
||||
*/
|
||||
private function provisionDoctor(ClinicDoctorInvitation $inv): Doctor
|
||||
{
|
||||
$doctor = $inv->getDoctor() ?? $this->doctorRepo->findOneByMobile($inv->getMobile());
|
||||
if ($doctor !== null) {
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
$mobile = $inv->getMobile();
|
||||
$name = $inv->getInvitedName() ?: $mobile;
|
||||
|
||||
$user = $this->userRepo->findOneBy(['mobileNumber' => $mobile]);
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
$user->setRealName($name);
|
||||
$this->em->persist($user);
|
||||
}
|
||||
$user->addRole('ROLE_DOCTOR');
|
||||
|
||||
$doctor = $this->doctorRepo->findOneBy(['user' => $user]);
|
||||
if ($doctor === null) {
|
||||
$doctor = new Doctor($user, $user->getRealName() ?: $name);
|
||||
$doctor->setMobileNumber($mobile);
|
||||
$doctor->setOwnerStatus('unclaimed');
|
||||
$this->em->persist($doctor);
|
||||
}
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/**
|
||||
* برای کاربری که هنوز رمز عبور ندارد یک رمز تولید میکند تا بتواند وارد شود.
|
||||
* رمز کاربران موجود هرگز بازنویسی نمیشود.
|
||||
*
|
||||
* @return string|null رمز خام برای ارسال پیامک، یا null اگر کاربر از قبل رمز داشته
|
||||
*/
|
||||
private function ensureLoginCredentials(User $user): ?string
|
||||
{
|
||||
if ($user->getPasswordHash() !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$password = bin2hex(random_bytes(4));
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
public function reject(ClinicDoctorInvitation $inv): void
|
||||
@@ -117,4 +220,13 @@ class ClinicInvitationService
|
||||
'link' => $link,
|
||||
]);
|
||||
}
|
||||
|
||||
private function sendCredentialsSms(string $mobile, string $password): void
|
||||
{
|
||||
$this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_PRE_REGISTRATION, $mobile, [
|
||||
'username' => $mobile,
|
||||
'password' => $password,
|
||||
'link' => rtrim($this->appUrl, '/') . '/admin',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\ClinicInvitation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Covers the full clinic → doctor invitation lifecycle: inviting provisions a
|
||||
* doctor profile, accepting creates login credentials and links the doctor to
|
||||
* the clinic, and every admin action on an invitation stays reachable.
|
||||
*/
|
||||
class ClinicInvitationFlowTest extends ApiTestCase
|
||||
{
|
||||
private function createClinicOwner(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک تست');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic];
|
||||
}
|
||||
|
||||
private function invitedMobile(): string
|
||||
{
|
||||
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public function testInviteCreatesDoctorProfileWithoutLinkingClinic(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$mobile = $this->invitedMobile();
|
||||
|
||||
$res = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
'name' => 'دکتر تست',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertNotNull($res['data']['doctor'], 'invite must provision a doctor profile');
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
self::assertNotNull($doctor);
|
||||
self::assertSame('unclaimed', $doctor->getOwnerStatus());
|
||||
self::assertNull($doctor->getUser()->getPasswordHash(), 'no password before acceptance');
|
||||
|
||||
$this->em->refresh($clinic);
|
||||
self::assertFalse($clinic->getDoctors()->contains($doctor), 'clinic link happens only on accept');
|
||||
}
|
||||
|
||||
public function testAcceptLinksDoctorAndIssuesLoginCredentials(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$mobile = $this->invitedMobile();
|
||||
|
||||
$this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
'name' => 'دکتر تست',
|
||||
]);
|
||||
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]);
|
||||
$this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept");
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]);
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
|
||||
self::assertSame(ClinicDoctorInvitation::STATUS_ACCEPTED, $inv->getStatus());
|
||||
self::assertSame($doctor->getId(), $inv->getDoctor()?->getId());
|
||||
self::assertSame('claimed', $doctor->getOwnerStatus());
|
||||
self::assertNotNull($user->getPasswordHash(), 'accepted doctor must be able to log in');
|
||||
self::assertContains('ROLE_DOCTOR', $user->getRoles());
|
||||
self::assertTrue($inv->getClinic()->getDoctors()->contains($doctor));
|
||||
}
|
||||
|
||||
public function testAcceptDoesNotOverwriteExistingPassword(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$existing = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$existing->setPasswordHash('$2y$13$alreadySetHashValueForTesting.aaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
$this->em->flush();
|
||||
$mobile = $existing->getMobileNumber();
|
||||
|
||||
$this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
]);
|
||||
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]);
|
||||
$this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept");
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
self::assertStringStartsWith('$2y$13$alreadySet', $reloaded->getPasswordHash());
|
||||
}
|
||||
|
||||
public function testSuspendAndReactivateInvitation(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$mobile = $this->invitedMobile();
|
||||
|
||||
$created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
]);
|
||||
$invUuid = $created['data']['uuid'];
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$invUuid}/status", $owner, ['status' => 'suspended']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$res = $this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$invUuid}/status", $owner, ['status' => 'pending']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('pending', $res['data']['status']);
|
||||
|
||||
$this->em->clear();
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['uuid' => $invUuid]);
|
||||
self::assertTrue($inv->isUsable(), 'reactivated invitation must have a fresh usable token');
|
||||
}
|
||||
|
||||
public function testInvalidStatusIsRejected(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $this->invitedMobile(),
|
||||
]);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}/status", $owner, ['status' => 'bogus']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDeleteInvitationReturnsJsonBody(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $this->invitedMobile(),
|
||||
]);
|
||||
|
||||
$res = $this->authJson('DELETE', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}", $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['success']);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}", $owner);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAcceptedInvitationCannotReturnToPending(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$mobile = $this->invitedMobile();
|
||||
$created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
]);
|
||||
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]);
|
||||
$this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept");
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}/status", $owner, ['status' => 'pending']);
|
||||
self::assertSame(409, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user