248 lines
9.9 KiB
PHP
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',
|
|
]);
|
|
}
|
|
}
|