feat: add ClinicInvitationWebController and related templates for handling clinic invitations

- Implemented ClinicInvitationWebController to manage the invitation process via web.
- Added view and respond methods to handle invitation display and responses.
- Created result.html.twig and view.html.twig templates for rendering invitation results and views.
- Integrated CSRF protection for form submissions.
- Established routes for invitation viewing and responding.
This commit is contained in:
hamed
2026-07-11 09:33:55 +03:30
parent a5e3408e85
commit 744a40c0f6
19 changed files with 2125 additions and 671 deletions
@@ -0,0 +1,96 @@
<?php
namespace App\ClinicInvitation\Controller;
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\ClinicInvitation\Service\ClinicInvitationService;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* صفحات HTML عمومی دعوت پزشک — لینک پیامک اینجا باز می‌شود (بدون JWT).
* جدا از ClinicInvitationController که نسخهٔ JSON (/api/v1/...) را برای کلاینت React/اپ سِرو می‌کند.
*/
class ClinicInvitationWebController extends BaseController
{
public function __construct(
private readonly ClinicDoctorInvitationRepository $invRepo,
private readonly ClinicInvitationService $invitationService,
private readonly string $appUrl,
) {}
#[Route('/clinic-invitation/{token}', methods: ['GET'], name: 'invitation_web_view')]
#[Route('/i/{token}', methods: ['GET'], name: 'invitation_web_view_short')]
public function view(string $token): Response
{
$inv = $this->invRepo->findByToken($token);
if ($inv === null) {
return $this->render('invitation/result.html.twig', ['state' => 'notfound'])->setStatusCode(404);
}
if (!$inv->isUsable()) {
return $this->render('invitation/result.html.twig', [
'state' => $this->stateFromStatus($inv),
'admin_url' => $this->adminUrl(),
]);
}
return $this->render('invitation/view.html.twig', [
'token' => $token,
'clinic' => $inv->getClinic()->getName() ?: 'کلینیک',
'doctor' => $inv->getInvitedName(),
'expires_at' => $inv->getExpiresAt(),
]);
}
#[Route('/clinic-invitation/{token}/respond', methods: ['POST'], name: 'invitation_web_respond')]
public function respond(string $token, Request $request): Response
{
$inv = $this->invRepo->findByToken($token);
if ($inv === null) {
return $this->render('invitation/result.html.twig', ['state' => 'notfound'])->setStatusCode(404);
}
if (!$this->isCsrfTokenValid('invitation_' . $token, (string) $request->request->get('_token'))) {
return $this->render('invitation/result.html.twig', ['state' => 'expired'])->setStatusCode(403);
}
$action = (string) $request->request->get('action', '');
try {
if ($action === 'accept') {
$this->invitationService->accept($inv);
return $this->render('invitation/result.html.twig', [
'state' => 'accepted',
'admin_url' => $this->adminUrl(),
]);
}
if ($action === 'reject') {
$this->invitationService->reject($inv);
return $this->render('invitation/result.html.twig', ['state' => 'rejected']);
}
} catch (AppException) {
return $this->render('invitation/result.html.twig', ['state' => 'expired']);
}
return $this->render('invitation/result.html.twig', ['state' => 'expired'])->setStatusCode(422);
}
private function stateFromStatus(ClinicDoctorInvitation $inv): string
{
return match ($inv->getStatus()) {
ClinicDoctorInvitation::STATUS_ACCEPTED => 'accepted',
ClinicDoctorInvitation::STATUS_REJECTED => 'rejected',
default => 'expired',
};
}
private function adminUrl(): string
{
return rtrim($this->appUrl, '/') . '/admin';
}
}
@@ -74,7 +74,7 @@ class ClinicDoctorInvitation
$this->clinic = $clinic;
$this->invitedBy = $invitedBy;
$this->mobile = $mobile;
$this->token = bin2hex(random_bytes(16));
$this->token = bin2hex(random_bytes(6));
$this->invitedAt = time();
$this->expiresAt = $this->invitedAt + 72 * 3600;
}
@@ -94,7 +94,7 @@ class ClinicDoctorInvitation
public function refresh(): void
{
$this->token = bin2hex(random_bytes(16));
$this->token = bin2hex(random_bytes(6));
$this->tokenUsed = false;
$this->invitedAt = time();
$this->expiresAt = $this->invitedAt + 72 * 3600;
@@ -110,7 +110,7 @@ class ClinicInvitationService
private function sendSms(ClinicDoctorInvitation $inv, Clinic $clinic): void
{
$clinicName = $clinic->getName() ?? 'کلینیک';
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
$link = rtrim($this->appUrl, '/') . '/i/' . $inv->getToken();
$this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, $inv->getMobile(), [
'clinic' => $clinicName,