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;
}
}