feat: add clinic doctor invitation feature

- Implemented ClinicInvitationController to handle doctor invitations.
- Created ClinicDoctorInvitation entity and repository for managing invitations.
- Added ClinicInvitationService for business logic related to invitations.
- Introduced endpoints for inviting, listing, resending, changing status, and deleting invitations.
- Updated security configuration to allow public access to invitation endpoints.
- Added migration for clinic_doctor_invitations table.
- Enhanced DoctorRepository with a method to find doctors by mobile number.
- Updated ClinicDetailPage to include invitation management UI.
This commit is contained in:
hamed
2026-06-10 22:13:39 +03:30
parent 9ef94043c8
commit af0ae51987
9 changed files with 747 additions and 34 deletions
@@ -0,0 +1,104 @@
<?php
namespace App\ClinicInvitation\Service;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Exception\AppException;
use App\Sms\Service\SmsService;
use Doctrine\ORM\EntityManagerInterface;
class ClinicInvitationService
{
public function __construct(
private readonly ClinicDoctorInvitationRepository $repo,
private readonly DoctorRepository $doctorRepo,
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);
$doctor = $this->doctorRepo->findOneByMobile($mobile);
if ($doctor !== null) {
$inv->setDoctor($doctor);
}
$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_SUSPENDED, ClinicDoctorInvitation::STATUS_REMOVED];
if (!in_array($status, $allowed, true)) {
throw new AppException('ERR_VALIDATION_001', 'وضعیت نامعتبر است', 422);
}
$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);
}
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
$inv->markUsed();
$this->em->flush();
}
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, '/') . '/clinic-invitation/' . $inv->getToken();
$message = "دکتر گرامی، کلینیک {$clinicName} شما را برای همکاری دعوت کرده است.\n"
. "برای بررسی: {$link}\n"
. "این لینک تا ۷۲ ساعت معتبر است.";
$this->smsService->dispatchAsync($inv->getMobile(), $message);
}
}