Files
clinicpro/src/ClinicInvitation/Service/ClinicInvitationService.php
T

248 lines
9.9 KiB
PHP

<?php
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\Shared\Util\DisplayName;
use App\Sms\Service\SmsService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class ClinicInvitationService
{
private const UNNAMED_DOCTOR = 'پزشک دعوت‌شده';
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,
) {}
public function invite(Clinic $clinic, User $invitedBy, string $mobile, ?string $name, ?string $specialty): ClinicDoctorInvitation
{
$existing = $this->repo->findPendingByMobileAndClinic($mobile, $clinic->getId());
if ($existing !== null) {
throw new AppException('ERR_CONFLICT_001', 'این شماره قبلاً برای این کلینیک دعوت شده است', 409);
}
$inv = new ClinicDoctorInvitation($clinic, $invitedBy, $mobile);
$inv->setInvitedName($name);
$inv->setInvitedSpecialty($specialty);
// پروفایل پزشک همین‌جا ساخته می‌شود تا بلافاصله پس از دعوت قابل مشاهده باشد،
// ولی بدون رمز عبور و بدون اتصال به کلینیک — اتصال فقط پس از پذیرش انجام می‌شود.
$inv->setDoctor($this->provisionDoctor($inv));
$this->repo->save($inv);
$this->sendSms($inv, $clinic);
return $inv;
}
public function resend(ClinicDoctorInvitation $inv): void
{
if (in_array($inv->getStatus(), [ClinicDoctorInvitation::STATUS_REMOVED, ClinicDoctorInvitation::STATUS_ACCEPTED], true)) {
throw new AppException('ERR_CONFLICT_001', 'امکان ارسال مجدد دعوتنامه وجود ندارد', 409);
}
$inv->refresh();
$this->em->flush();
$this->sendSms($inv, $inv->getClinic());
}
public function changeStatus(ClinicDoctorInvitation $inv, string $status): void
{
$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();
}
public function delete(ClinicDoctorInvitation $inv): void
{
$this->em->remove($inv);
$this->em->flush();
}
public function accept(ClinicDoctorInvitation $inv): void
{
if (!$inv->isUsable()) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
}
$password = $this->em->wrapInTransaction(function () use ($inv): ?string {
$doctor = $this->attachDoctorToClinic($inv);
$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) {
return $doctor;
}
$mobile = $inv->getMobile();
// شمارهٔ موبایل نامِ پزشک نیست. دعوت بدون نام قبلاً موبایل را به‌عنوان نام
// می‌نشاند و همان رکورد در نتایج عمومی و <title> صفحات سایت منتشر می‌شد.
$name = $this->resolveInvitedName($inv->getInvitedName());
$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, $this->resolveInvitedName($user->getRealName()));
$doctor->setMobileNumber($mobile);
$doctor->setOwnerStatus('unclaimed');
$this->em->persist($doctor);
}
return $doctor;
}
/**
* نام پزشکِ دعوت‌شده. کلینیک اغلب فقط شمارهٔ موبایل را دارد، پس نام واقعی هنوز
* ناشناخته است — یک برچسب خنثی می‌نشیند تا وقتی خود پزشک پروفایلش را claim کند.
* هرگز موبایل یا مقدار آزمایشی برنمی‌گرداند.
*/
private function resolveInvitedName(?string $candidate): string
{
return DisplayName::isPlaceholder($candidate) ? self::UNNAMED_DOCTOR : $candidate;
}
/**
* برای کاربری که هنوز رمز عبور ندارد یک رمز تولید می‌کند تا بتواند وارد شود.
* رمز کاربران موجود هرگز بازنویسی نمی‌شود.
*
* @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
{
if (!$inv->isUsable()) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
}
$inv->setStatus(ClinicDoctorInvitation::STATUS_REJECTED);
$inv->markUsed();
$this->em->flush();
}
private function sendSms(ClinicDoctorInvitation $inv, Clinic $clinic): void
{
$clinicName = $clinic->getName() ?? 'کلینیک';
$link = rtrim($this->appUrl, '/') . '/i/' . $inv->getToken();
$this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, $inv->getMobile(), [
'clinic' => $clinicName,
'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',
]);
}
}