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:
hamed
2026-07-18 09:14:13 +03:30
co-authored by Claude Opus 4.8
parent 1779e0d6de
commit 3a23aa242e
9 changed files with 689 additions and 33 deletions
@@ -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' })}
>
+5
View File
@@ -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>;
}
+15 -6
View File
@@ -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>
);
}
+43 -6
View File
@@ -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());
}
}