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' => 'دعوتنامه رد شد']);
}
}