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,171 @@
<?php
namespace App\ClinicInvitation\Controller;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\ClinicInvitation\Service\ClinicInvitationService;
use App\Clinic\Repository\ClinicRepository;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ClinicInvitationController extends BaseController
{
public function __construct(
private readonly ClinicInvitationService $invitationService,
private readonly ClinicDoctorInvitationRepository $invRepo,
private readonly ClinicRepository $clinicRepo,
) {}
// ── Admin endpoints ──────────────────────────────────────────────────────
#[Route('/api/v1/admin/clinic/{uuid}/invite-doctor', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function inviteDoctor(string $uuid, Request $request): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($uuid);
if (!$clinic) {
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
}
$body = json_decode($request->getContent(), true) ?? [];
$mobile = trim($body['mobile'] ?? '');
$name = !empty($body['name']) ? trim($body['name']) : null;
$specialty = !empty($body['specialty']) ? trim($body['specialty']) : null;
if (!$mobile || !preg_match('/^09\d{9}$/', $mobile)) {
throw new AppException('ERR_VALIDATION_001', 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$inv = $this->invitationService->invite($clinic, $this->getUser(), $mobile, $name, $specialty);
return $this->success($inv->toArray(), 201);
}
#[Route('/api/v1/admin/clinic/{uuid}/invitations', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function listInvitations(string $uuid, Request $request): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($uuid);
if (!$clinic) {
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
}
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$qb = $this->invRepo->createQueryBuilder('i')
->leftJoin('i.doctor', 'd')
->where('i.clinic = :clinic')
->setParameter('clinic', $clinic)
->orderBy('i.invitedAt', 'DESC');
$total = (clone $qb)->select('COUNT(i.id)')->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
$data = array_map(fn($inv) => $inv->toArray(), $items);
return $this->paginated($data, (int) $total, $page, $limit);
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/resend', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function resendInvitation(string $invUuid): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->invitationService->resend($inv);
return $this->success(['message' => 'پیامک مجدداً ارسال شد']);
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/status', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function changeInvitationStatus(string $invUuid, Request $request): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$body = json_decode($request->getContent(), true) ?? [];
$status = $body['status'] ?? '';
$this->invitationService->changeStatus($inv, $status);
return $this->success(['status' => $inv->getStatus()]);
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteInvitation(string $invUuid): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->invitationService->delete($inv);
return $this->success(null, 204);
}
// ── Public endpoints ──────────────────────────────────────────────────────
#[Route('/api/v1/clinic-invitation/{token}', methods: ['GET'])]
public function viewInvitation(string $token): JsonResponse
{
$inv = $this->invRepo->findByToken($token);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$clinic = $inv->getClinic();
return $this->success([
'invitation' => $inv->toArray(),
'clinic' => [
'uuid' => $clinic->getUuid(),
'name' => $clinic->getName(),
'logo' => $clinic->getClinicLogo(),
],
'is_usable' => $inv->isUsable(),
]);
}
#[Route('/api/v1/clinic-invitation/{token}/accept', methods: ['POST'])]
public function acceptInvitation(string $token): JsonResponse
{
$inv = $this->invRepo->findByToken($token);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->invitationService->accept($inv);
return $this->success(['message' => 'دعوتنامه پذیرفته شد']);
}
#[Route('/api/v1/clinic-invitation/{token}/reject', methods: ['POST'])]
public function rejectInvitation(string $token): JsonResponse
{
$inv = $this->invRepo->findByToken($token);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->invitationService->reject($inv);
return $this->success(['message' => 'دعوتنامه رد شد']);
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\ClinicInvitation\Entity;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ClinicDoctorInvitationRepository::class)]
#[ORM\Table(name: 'clinic_doctor_invitations')]
#[ORM\Index(columns: ['token'], name: 'idx_cdi_token')]
#[ORM\Index(columns: ['clinic_id'], name: 'idx_cdi_clinic')]
#[ORM\Index(columns: ['mobile'], name: 'idx_cdi_mobile')]
class ClinicDoctorInvitation
{
public const STATUS_PENDING = 'pending';
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_REJECTED = 'rejected';
public const STATUS_SUSPENDED = 'suspended';
public const STATUS_REMOVED = 'removed';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Clinic $clinic;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'invited_by_id', referencedColumnName: 'id', nullable: false)]
private User $invitedBy;
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Doctor $doctor = null;
#[ORM\Column(type: 'string', length: 20)]
private string $mobile;
#[ORM\Column(name: 'invited_name', type: 'string', length: 255, nullable: true)]
private ?string $invitedName = null;
#[ORM\Column(name: 'invited_specialty', type: 'string', length: 255, nullable: true)]
private ?string $invitedSpecialty = null;
#[ORM\Column(type: 'string', length: 128, unique: true)]
private string $token;
#[ORM\Column(name: 'token_used', type: 'boolean')]
private bool $tokenUsed = false;
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(name: 'invited_at', type: 'integer')]
private int $invitedAt;
#[ORM\Column(name: 'expires_at', type: 'integer')]
private int $expiresAt;
#[ORM\Column(name: 'responded_at', type: 'integer', nullable: true)]
private ?int $respondedAt = null;
public function __construct(Clinic $clinic, User $invitedBy, string $mobile)
{
$this->uuid = \Symfony\Component\Uid\Uuid::v4()->toRfc4122();
$this->clinic = $clinic;
$this->invitedBy = $invitedBy;
$this->mobile = $mobile;
$this->token = bin2hex(random_bytes(48));
$this->invitedAt = time();
$this->expiresAt = $this->invitedAt + 72 * 3600;
}
public function isUsable(): bool
{
return $this->status === self::STATUS_PENDING
&& !$this->tokenUsed
&& time() < $this->expiresAt;
}
public function markUsed(): void
{
$this->tokenUsed = true;
$this->respondedAt = time();
}
public function refresh(): void
{
$this->token = bin2hex(random_bytes(48));
$this->tokenUsed = false;
$this->invitedAt = time();
$this->expiresAt = $this->invitedAt + 72 * 3600;
$this->status = self::STATUS_PENDING;
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'mobile' => $this->mobile,
'invited_name' => $this->invitedName,
'invited_specialty' => $this->invitedSpecialty,
'status' => $this->status,
'token_used' => $this->tokenUsed,
'invited_at' => $this->invitedAt,
'expires_at' => $this->expiresAt,
'responded_at' => $this->respondedAt,
'doctor' => $this->doctor ? [
'uuid' => $this->doctor->getUuid(),
'name' => $this->doctor->getName(),
] : null,
];
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getClinic(): Clinic { return $this->clinic; }
public function getDoctor(): ?Doctor { return $this->doctor; }
public function getMobile(): string { return $this->mobile; }
public function getToken(): string { return $this->token; }
public function getStatus(): string { return $this->status; }
public function getInvitedAt(): int { return $this->invitedAt; }
public function getExpiresAt(): int { return $this->expiresAt; }
public function isTokenUsed(): bool { return $this->tokenUsed; }
public function getInvitedName(): ?string { return $this->invitedName; }
public function getInvitedSpecialty(): ?string { return $this->invitedSpecialty; }
public function setDoctor(?Doctor $doctor): void { $this->doctor = $doctor; }
public function setStatus(string $status): void { $this->status = $status; }
public function setInvitedName(?string $n): void { $this->invitedName = $n; }
public function setInvitedSpecialty(?string $s): void { $this->invitedSpecialty = $s; }
}
@@ -0,0 +1,41 @@
<?php
namespace App\ClinicInvitation\Repository;
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClinicDoctorInvitationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClinicDoctorInvitation::class);
}
public function findByToken(string $token): ?ClinicDoctorInvitation
{
return $this->findOneBy(['token' => $token]);
}
public function findPendingByMobileAndClinic(string $mobile, int $clinicId): ?ClinicDoctorInvitation
{
return $this->createQueryBuilder('i')
->where('i.mobile = :mobile')
->andWhere('i.clinic = :clinicId')
->andWhere('i.status = :status')
->setParameter('mobile', $mobile)
->setParameter('clinicId', $clinicId)
->setParameter('status', ClinicDoctorInvitation::STATUS_PENDING)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function save(ClinicDoctorInvitation $invitation): void
{
$em = $this->getEntityManager();
$em->persist($invitation);
$em->flush();
}
}
@@ -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);
}
}
@@ -25,6 +25,17 @@ class DoctorRepository extends ServiceEntityRepository
return $this->findOneBy(['user' => $user]);
}
public function findOneByMobile(string $mobile): ?Doctor
{
return $this->createQueryBuilder('d')
->join('d.user', 'u')
->where('u.mobileNumber = :mobile')
->setParameter('mobile', $mobile)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function findWithFilters(array $filters): array
{
$page = max(1, (int) ($filters['page'] ?? 1));