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>
18 KiB
اصلاح کامل فرآیند دعوت پزشک به کلینیک (Clinic Doctor Invitation)
پروژه
clinicpro (backend Symfony + پنل ادمین React). مصرفکنندهای در nobat724_front ندارد — صفحه پذیرش دعوتنامه Twig سمت خود Symfony است (/i/{token}).
زمینه
کلینیک «علی بهروزی» (موبایل مالک 09024206041) از مسیر /admin/settings/clinic-doctors → «دعوت از پزشکان» یک دعوتنامه برای «دکتر تست» با موبایل 09100652121 ارسال کرده است. هیچکدام از سه مرحلهٔ فرآیند درست کار نمیکند:
- بعد از ارسال دعوتنامه هیچ پروفایل پزشکی ساخته نمیشود.
- بعد از باز کردن لینک تأیید و زدن «تأیید»، عملاً هیچ اتفاقی نمیافتد؛ پزشک نمیتواند با
09100652121وارد شود و به کلینیک وصل نمیشود. - در صفحهٔ
/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
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 میفرستد:
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 اضافه کن که:
- اگر
$inv->getDoctor()موجود بود همان را برگرداند. - وگرنه با
doctorRepo->findOneByMobile($inv->getMobile())جستوجو کند. - وگرنه
Userرا باuserRepo->findOneBy(['mobileNumber' => $inv->getMobile()])پیدا یا بسازد؛ اگر ساخت جدید بود، پسورد تصادفی تولید کند، با hasher هش کند و حتماً با SMS برای پزشک بفرستد (بدون این کار پزشک باز هم نمیتواند وارد شود). ROLE_DOCTORرا به کاربر اضافه کند (addRoleمثلPreRegistrationController).- اگر کاربر
Doctorندارد،new Doctor($user, $name)بسازد وsetMobileNumber()را ست کند. نام از$inv->getName()(عنوان واردشده در دعوت، مثلاً «دکتر تست») و در نبودش از$user->getRealName()یا خود موبایل.
الگوی مرجع — src/Auth/Controller/PreRegistrationController.php:119-142:
$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() را بازنویسی کن:
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 را به وایتلیست اضافه کن:
$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) این سناریو را کامل اجرا کن:
- دعوت پزشک جدید با موبایل تستی.
- باز کردن
/i/{token}و زدن «تأیید». - بررسی در DB:
usersسطر جدید باROLE_DOCTOR،doctorsسطر جدید،clinic_doctorsسطر پیوند،clinic_doctor_invitations.doctor_idپرشده. - لاگین با آن موبایل و پسورد SMSشده از
POST /api/v1/user/login(یا OTP: در dev کد همیشه12345). - فراخوانی
GET /api/v1/doctor/invitationsبا توکن پزشک — نباید 404 بدهد. - بازگشت به
/admin/settings/clinic-doctors— پزشک باید در لیست پزشکان کلینیک دیده شود.
نکات مهم
- همهٔ controllerها از
BaseControllerارث میبرند؛ پاسخها فقط با$this->success()/$this->paginated()/$this->error(). ClinicDoctorentity وجود ندارد — پیوند یک 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و:95whitelist شدهاند؛ اگر 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.