Files
clinicpro/.claude/prompt/fix-clinic-doctor-invitation-flow.md
hamedandClaude Opus 4.8 3a23aa242e 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>
2026-07-18 09:14:13 +03:30

18 KiB
Raw Permalink Blame History

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

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() صدا می‌زند → SyntaxErrordeleteInvMut.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:

$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:143new 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:59pending را به وایت‌لیست اضافه کن:

$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:33POST /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.