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,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);
}
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
$inv->markUsed();
$password = $this->em->wrapInTransaction(function () use ($inv): ?string {
$doctor = $this->attachDoctorToClinic($inv);
$doctor = $inv->getDoctor();
if ($doctor === null) {
$doctor = $this->doctorRepo->findOneByMobile($inv->getMobile());
if ($doctor !== null) {
$inv->setDoctor($doctor);
}
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
$inv->markUsed();
return $this->ensureLoginCredentials($doctor->getUser());
});
if ($password !== null) {
$this->sendCredentialsSms($inv->getMobile(), $password);
}
}
/**
* ترمیم دعوت‌نامه‌ای که قبلاً «پذیرفته‌شده» ثبت شده ولی پروفایل پزشک برایش ساخته نشده است.
* برخلاف 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;
}
/**
* پروفایل پزشک متناظر با شماره موبایل دعوت‌نامه را برمی‌گرداند و در صورت نبود
* کاربر و پروفایل را می‌سازد. رمز عبور اینجا ست نمی‌شود — آن کار فقط هنگام پذیرش.
*/
private function provisionDoctor(ClinicDoctorInvitation $inv): Doctor
{
$doctor = $inv->getDoctor() ?? $this->doctorRepo->findOneByMobile($inv->getMobile());
if ($doctor !== null) {
$clinic = $inv->getClinic();
if (!$clinic->getDoctors()->contains($doctor)) {
$clinic->getDoctors()->add($doctor);
}
return $doctor;
}
$this->em->flush();
$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',
]);
}
}