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,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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user